1 line
4.9 MiB
1 line
4.9 MiB
{"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":"<p>Stop feeding your secrets to corporations. Own your political infrastructure.</p> <p>Changemaker Lite V2 is Now Available</p> <p>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.</p> <p>\u2192 Explore V2 Documentation \u2192 Quick Start Guide</p>"},{"location":"#changemaker-lite-v2","title":"Changemaker Lite V2","text":"<p>V2 is the recommended version for all new installations. It offers:</p>"},{"location":"#modern-architecture","title":"\u2728 Modern Architecture","text":"<ul> <li>Dual API Design: Express.js (main features) + Fastify (media library)</li> <li>TypeScript Throughout: Type-safe development with better IDE support</li> <li>Prisma + Drizzle ORM: Direct database access (no NocoDB middleware)</li> <li>React Admin: Modern UI with Vite + Ant Design + Zustand</li> </ul>"},{"location":"#comprehensive-features","title":"\ud83d\ude80 Comprehensive Features","text":"<ul> <li>Influence Module: Email advocacy campaigns targeting elected representatives</li> <li>Map Module: Geographic mapping with GPS-tracked canvassing</li> <li>Landing Pages: GrapesJS page builder with MkDocs export</li> <li>Email Templates: Template management system</li> <li>Media Library: Video management with public gallery</li> <li>Newsletter Integration: Listmonk sync for email marketing</li> <li>Observability: Prometheus + Grafana monitoring stack</li> </ul>"},{"location":"#production-ready","title":"\ud83d\udd12 Production Ready","text":"<ul> <li>Security Audited: 13 findings addressed (Feb 2026)</li> <li>JWT Authentication: Role-based access control with refresh tokens</li> <li>Password Policy: Enforced complexity requirements</li> <li>Rate Limiting: Per-endpoint protection</li> <li>Monitoring: 12 custom metrics + 3 Grafana dashboards</li> </ul>"},{"location":"#complete-documentation","title":"\ud83d\udcda Complete Documentation","text":"<ul> <li>Getting Started - Installation and setup</li> <li>Architecture - System design and components</li> <li>Features - Module documentation</li> <li>API Reference - Complete endpoint docs</li> <li>User Guides - Role-based manuals</li> </ul> <p>Explore V2 Documentation \u2192</p>"},{"location":"#changemaker-lite-v1-legacy","title":"Changemaker Lite V1 (Legacy)","text":"<p>V1 is Deprecated</p> <p>V1 documentation is preserved below for reference. We strongly recommend migrating to V2 for improved performance, security, and features.</p> <p>\u2192 View Migration Guide</p>"},{"location":"#quick-start-v1","title":"Quick Start (V1)","text":"<p>Get V1 running in minutes:</p> <pre><code># 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</code></pre>"},{"location":"#services","title":"Services","text":"<p>Changemaker Lite includes these essential services:</p>"},{"location":"#core-services","title":"Core Services","text":"<ul> <li>Homepage (Port 3010) - Central dashboard and service monitoring</li> <li>Code Server (Port 8888) - VS Code in your browser</li> <li>MkDocs (Port 4000) - Documentation with live preview</li> <li>Static Server (Port 4001) - Production documentation site</li> </ul>"},{"location":"#communication-automation","title":"Communication & Automation","text":"<ul> <li>Listmonk (Port 9000) - Newsletter and email campaign management</li> <li>n8n (Port 5678) - Workflow automation platform</li> </ul>"},{"location":"#data-development","title":"Data & Development","text":"<ul> <li>NocoDB (Port 8090) - No-code database platform</li> <li>PostgreSQL (Port 5432) - Database backend for Listmonk</li> <li>Gitea (Port 3030) - Self-hosted Git service</li> </ul>"},{"location":"#interactive-tools","title":"Interactive Tools","text":"<ul> <li>Map Viewer (Port 3000) - Interactive map with NocoDB integration</li> <li>Mini QR (Port 8089) - QR code generator</li> </ul>"},{"location":"#getting-started-v1","title":"Getting Started (V1)","text":"<ol> <li>Setup: Run <code>./config.sh</code> to configure your environment</li> <li>Launch: Start services with <code>docker compose up -d</code></li> <li>Dashboard: Access the Homepage at http://localhost:3010</li> <li>Production: Deploy with Cloudflare tunnels using <code>./start-production.sh</code></li> </ol>"},{"location":"#project-structure","title":"Project Structure","text":"<pre><code>changemaker.lite/\n\u251c\u2500\u2500 docker-compose.yml # Service definitions\n\u251c\u2500\u2500 config.sh # Configuration wizard\n\u251c\u2500\u2500 start-production.sh # Production deployment script\n\u251c\u2500\u2500 mkdocs/ # Documentation source\n\u2502 \u251c\u2500\u2500 docs/ # Markdown files\n\u2502 \u2514\u2500\u2500 mkdocs.yml # MkDocs configuration\n\u251c\u2500\u2500 configs/ # Service configurations\n\u2502 \u251c\u2500\u2500 homepage/ # Homepage dashboard config\n\u2502 \u251c\u2500\u2500 code-server/ # VS Code settings\n\u2502 \u2514\u2500\u2500 cloudflare/ # Tunnel configurations\n\u251c\u2500\u2500 map/ # Map application\n\u2502 \u251c\u2500\u2500 app/ # Node.js application\n\u2502 \u251c\u2500\u2500 Dockerfile # Container definition\n\u2502 \u2514\u2500\u2500 .env # Map configuration\n\u2514\u2500\u2500 assets/ # Shared assets\n \u251c\u2500\u2500 images/ # Image files\n \u251c\u2500\u2500 icons/ # Service icons\n \u2514\u2500\u2500 uploads/ # Listmonk uploads\n</code></pre>"},{"location":"#key-features","title":"Key Features","text":"<ul> <li>\ud83d\udc33 Fully Containerized - All services run in Docker containers</li> <li>\ud83d\udd12 Production Ready - Built-in Cloudflare tunnel support for secure access</li> <li>\ud83d\udce6 All-in-One - Everything you need for documentation, development, and campaigns</li> <li>\ud83d\uddfa\ufe0f Geographic Data - Interactive maps with real-time location tracking</li> <li>\ud83d\udce7 Email Campaigns - Professional newsletter management</li> <li>\ud83d\udd04 Automation - Connect services and automate workflows</li> <li>\ud83d\udcbe Version Control - Self-hosted Git repository</li> <li>\ud83c\udfaf No-Code Database - Build applications without programming</li> </ul>"},{"location":"#system-requirements","title":"System Requirements","text":"<ul> <li>OS: Ubuntu 24.04 LTS (Noble Numbat) or compatible Linux distribution</li> <li>Docker: Version 24.0+ with Docker Compose v2</li> <li>Memory: Minimum 4GB RAM (8GB recommended)</li> <li>Storage: 20GB+ available disk space</li> <li>Network: Internet connection for initial setup</li> </ul>"},{"location":"#learn-more-v1","title":"Learn More (V1)","text":"<ul> <li>Getting Started - Detailed installation guide</li> <li>Services Overview - Deep dive into each service</li> <li>Blog - Updates and tutorials</li> <li>GitHub Repository - Source code</li> </ul>"},{"location":"blog/2025/07/03/blog-1/","title":"Blog 1","text":"<p>Hello! Just putting something up here because, well, gosh darn, feels like the right thing to do. </p> <p>Making swift progress. Can now write things fast as heck lad. </p>"},{"location":"blog/2025/07/10/2/","title":"2","text":"<p>Wow. Big build day. Added (admittedly still buggy) shifts support to the system. Power did it in a day. </p> <p>Other updates recently include: </p> <ul> <li>Fully reworked backend <code>server.js</code> into modular components. </li> <li>Bunch of mobile related fixes and improvements.</li> <li>Bi-directional saving of configs fixed up </li> <li>Some style upgrades </li> </ul> <p>Need to make more content about how to use the system in general too. </p>"},{"location":"blog/2025/08/01/3/","title":"3","text":"<p>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.</p> <p>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.</p>"},{"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":"<p>Below is a summary of all changes pushed to git in the last month:</p> <ul> <li>Admin Panel & NocoDB Integration: Major updates to the admin section, including a new NocoDB admin area, improved database search, and code cleanups.</li> <li>Website & UI Updates: Numerous updates to the website, including language tweaks, mobile friendliness, and new frontend features.</li> <li>Shifts Management: Comprehensive volunteer shift management system added, with calendar/grid views, admin controls, and real-time updates.</li> <li>Authentication & User Management: Enhanced login system, password recovery via SMTP, user management panel for admins, and role-based access control.</li> <li>Map & Geocoding: Improved map display, apartment views, geocoding integration, and address confirmation system.</li> <li>Unified Search System: Powerful search bar (Ctrl+K) for docs and address search, with real-time results, caching, and QR code generation.</li> <li>Data Import & Conversion: CSV data import with batch geocoding and visual progress, plus a new data converter tool.</li> <li>Email & Notifications: SMTP integration for email notifications and password recovery.</li> <li>Performance & Bug Fixes: Numerous bug fixes, code cleanups, and performance improvements across the stack.</li> <li>Docker & Deployment: Docker containerization, improved build scripts, and easier multi-instance deployment.</li> <li>Documentation: Expanded and updated documentation, including new manuals and guides.</li> </ul> <p>For a detailed commit log, see <code>git-report.txt</code>.</p>"},{"location":"blog/2025/08/01/3/#overview-of-landerhtml","title":"Overview of <code>lander.html</code>","text":"<p>The <code>lander.html</code> file is a modern, responsive landing page for Changemaker Lite, featuring:</p> <ul> <li>Custom Theming: Light/dark mode toggle with persistent user preference.</li> <li>Sticky Header & Navigation: Fixed header with smooth scroll and navigation links.</li> <li>Hero Section: Prominent introduction with call-to-action buttons.</li> <li>Search Integration: Inline MkDocs search with real-time results and keyboard shortcuts.</li> <li>Feature Showcases: Sections for problems, solutions, power tools, data ownership, pricing, integrations, testimonials, and live examples.</li> <li>Responsive Design: Mobile-friendly layout with adaptive grids and cards.</li> <li>Animations: Intersection observer for fade-in effects on cards and sections.</li> <li>Video & Media: Embedded video showcase and rich media support.</li> <li>Footer: Informative footer with links and contact info.</li> </ul> <p>The page is styled with CSS variables for easy theming and includes scripts for search, theme switching, and smooth scrolling.</p>"},{"location":"blog/2025/08/01/3/#new-features-in-map-readmemd","title":"New Features in Map (<code>README.md</code>)","text":"<p>The map application has received significant upgrades:</p> <ul> <li>Interactive Map: Real-time visualization with OpenStreetMap and Leaflet.js.</li> <li>Unified Search: Docs and address search in one bar, with keyboard shortcuts and smart caching.</li> <li>Geolocation & Add Locations: Real-time user geolocation and ability to add new locations directly from the map.</li> <li>Auto-Refresh: Map data auto-refreshes every 30 seconds.</li> <li>Responsive & Mobile Ready: Fully responsive design for all devices.</li> <li>Secure API Proxy: Protects credentials and secures API access.</li> <li>Admin Panel: System configuration, user management, and shift management for admins.</li> <li>Walk Sheet Generator: For door-to-door canvassing, with customizable titles and QR code integration.</li> <li>Volunteer Shifts: Calendar/grid views, signup/cancellation, admin shift creation, and real-time updates.</li> <li>Role-Based Access: Admin vs. user permissions throughout the app.</li> <li>Email Notifications: SMTP-based notifications and password recovery.</li> <li>CSV Import & Geocoding: Batch import with geocoding and progress tracking.</li> <li>Dockerized Deployment: Easy setup and scaling with Docker.</li> <li>Open Source: 100% open source, no proprietary dependencies.</li> </ul> <p>API Endpoints: Comprehensive REST API for locations, shifts, authentication, admin, and geocoding, all with rate limiting and security features.</p> <p>Database Schema: Auto-created tables for locations, users, settings, shifts, and signups, with detailed field definitions.</p> <p>For more details, see the full <code>README.md</code> and explore the live application.</p>"},{"location":"blog/2025/09/24/4/","title":"4","text":"<p>Okay! Wow! Its been nearly 2 months since I wrote a blog update for this system. </p> <p>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. </p> <p>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.</p>"},{"location":"blog/2025/09/24/4/#what-weve-built-since-august","title":"What We've Built Since August","text":"<p>Here's a quick rundown of everything we've committed to the codebase over the past three months:</p>"},{"location":"blog/2025/09/24/4/#influence-app-major-launch","title":"Influence App - Major Launch","text":"<ul> <li>Complete UI Overhaul: Built an entirely new user interface and user system from the ground up</li> <li>Response Wall: Developed a comprehensive response wall system where elected officials can respond to campaigns, including verified response system with QR codes and verify buttons</li> <li>Campaign Management: Created new system for creating campaigns from the main site dashboard with campaign cover photos and phone numbers</li> <li>Social Features: Added social share buttons and site info improvements</li> <li>Geocoding Enhancements: Implemented automatic scanning of NocoDB locations to build geo-locations, plus premium Mapbox option for better street address matching</li> <li>User Management: Built password updater for users/admins and improved overall user management</li> <li>Network Integration: Integrated Influence into the Changemaker network</li> <li>Monitoring & Maintenance: Added health check utility, logger, metrics, backup, and SMTP toggle scripts</li> </ul>"},{"location":"blog/2025/09/24/4/#map-app-production-ready","title":"Map App - Production Ready","text":"<ul> <li>Map Cuts Feature: Built a comprehensive \"cuts\" system for dividing territories, including assignment workflows, print views, and spatial data handling</li> <li>Public Shifts: Implemented new public shifts system for volunteer coordination</li> <li>Performance: Optimized loading for maps with 1000+ locations and improved shift loading speeds</li> <li>Admin Improvements: Major refactor of admin.js into readable, maintainable files, plus new NocoDB admin section with database search</li> <li>Temp Users: Enhanced temporary user system with proper access controls and limited data sending</li> <li>Data Tools: Added CSV import reporting and ListMonk synchronization</li> <li>UI/UX: Standardized z-indexes, updated pop-ups, fixed menu bugs, and improved cut overlays</li> <li>CORS & Auth: Fixed authentication, lockouts, and CORS for local dev access</li> </ul>"},{"location":"blog/2025/09/24/4/#infrastructure-devops","title":"Infrastructure & DevOps","text":"<ul> <li>Documentation: Updated MkDocs documentation with search functionality</li> <li>Build System: Improved build-nocodb script to migrate data and auto-input URLs to .env</li> <li>Docker: Cleaned up docker-compose configuration and fixed container duplication issues</li> <li>Configuration: Updated homepage configs, Cloudflare tunnel settings, and general system configs</li> </ul> <p>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!</p>"},{"location":"how%20to/canvass/","title":"Canvas","text":"<p>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.</p>"},{"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":"<p>If you are a political actor, who do you trust with your secrets?</p> <p>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?</p>"},{"location":"phil/#the-corporate-extraction-machine","title":"The Corporate Extraction Machine","text":""},{"location":"phil/#how-they-hook-you","title":"How They Hook You","text":"<p>Corporate software companies have perfected the art of digital snake oil sales:</p> <ol> <li>Free Trials - They lure you in with \"free\" accounts</li> <li>Feature Creep - Essential features require paid tiers </li> <li>Data Lock-In - Your data becomes harder to export</li> <li>Price Escalation - $40/month becomes $750/month as you grow</li> <li>Surveillance Integration - Your organizing becomes their intelligence</li> </ol>"},{"location":"phil/#the-real-product","title":"The Real Product","text":"<p>You Are Not the Customer</p> <p>If you're not paying for the product, you ARE the product. But even when you are paying, you're often still the product.</p> <p>Corporate platforms don't make money from your subscription fees\u2014they make money from:</p> <ul> <li>Data Sales to third parties</li> <li>Algorithmic Manipulation for corporate and political interests </li> <li>Surveillance Contracts with governments and corporations</li> <li>Predictive Analytics about your community and movement</li> </ul>"},{"location":"phil/#the-bnkops-alternative","title":"The BNKops Alternative","text":""},{"location":"phil/#who-we-are","title":"Who We Are","text":"<p>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.</p>"},{"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":"<p>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? </p>"},{"location":"phil/#community-over-profit","title":"\ud83e\udd1d Community Over Profit","text":"<p>We operate as a cooperative because we believe in shared ownership and democratic decision-making. No venture capitalists, no shareholders, no extraction.</p>"},{"location":"phil/#data-sovereignty","title":"\u26a1 Data Sovereignty","text":"<p>Your data belongs to you and your community. We build tools that let you own your digital infrastructure completely.</p>"},{"location":"phil/#security-culture","title":"\ud83d\udd12 Security Culture","text":"<p>Real security comes from community control, not corporate promises. We integrate security culture practices into our technology design.</p>"},{"location":"phil/#why-this-matters","title":"Why This Matters","text":"<p>When you control your technology infrastructure:</p> <ul> <li>Your secrets stay secret - No corporate access to sensitive organizing data</li> <li>Your community stays connected - No algorithmic manipulation of your reach</li> <li>Your costs stay low - No extraction-based pricing as you grow</li> <li>Your future stays yours - No vendor lock-in or platform dependency</li> </ul>"},{"location":"phil/#the-philosophy-in-practice","title":"The Philosophy in Practice","text":""},{"location":"phil/#security-culture-meets-technology","title":"Security Culture Meets Technology","text":"<p>Traditional security culture asks: \"Who needs to know this information?\" </p> <p>Digital security culture asks: \"Who controls the infrastructure where this information lives?\"</p>"},{"location":"phil/#community-technology","title":"Community Technology","text":"<p>We believe in community technology - tools that:</p> <ul> <li>Are owned and controlled by the communities that use them</li> <li>Are designed with liberation politics from the ground up using free and open source software</li> <li>Prioritize care, consent, and collective power</li> <li>Can be understood, modified, and improved by community members</li> </ul>"},{"location":"phil/#prefigurative-politics","title":"Prefigurative Politics","text":"<p>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.</p>"},{"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":"<p>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.</p> <p>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. </p>"},{"location":"phil/#what-about-convenience","title":"\"What about convenience?\"","text":"<p>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.</p>"},{"location":"phil/#cant-we-just-use-corporate-tools-carefully","title":"\"Can't we just use corporate tools carefully?\"","text":"<p>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.</p>"},{"location":"phil/#what-about-security","title":"\"What about security?\"","text":"<p>Real security comes from community control, not corporate promises. When you control your infrastructure:</p> <ul> <li>You decide what gets logged and what doesn't</li> <li>You choose who has access and who doesn't </li> <li>You know exactly where your data is and who can see it</li> <li>You can't be de-platformed or locked out of your own data</li> </ul>"},{"location":"phil/#the-surveillance-capitalism-trap","title":"The Surveillance Capitalism Trap","text":"<p>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:</p> <ul> <li>Political data predicts behavior</li> <li>Movement intelligence can be used to counter-organize</li> <li>Community networks can be mapped and disrupted</li> <li>Organizing strategies can be monitored and neutralized</li> </ul>"},{"location":"phil/#taking-action","title":"Taking Action","text":""},{"location":"phil/#start-where-you-are","title":"Start Where You Are","text":"<p>You don't have to replace everything at once. Start with one tool, one campaign, one project. Learn the technology alongside your organizing.</p>"},{"location":"phil/#build-community-capacity","title":"Build Community Capacity","text":"<p>The goal isn't individual self-sufficiency\u2014it's community technological sovereignty. Share skills, pool resources, learn together.</p>"},{"location":"phil/#connect-with-others","title":"Connect with Others","text":"<p>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.</p>"},{"location":"phil/#remember-why","title":"Remember Why","text":"<p>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.</p>"},{"location":"phil/#resources-for-deeper-learning","title":"Resources for Deeper Learning","text":""},{"location":"phil/#essential-reading","title":"Essential Reading","text":"<ul> <li>De-corp Your Software Stack - Our full manifesto</li> <li>The Age of Surveillance Capitalism by Shoshana Zuboff</li> <li>Security Culture Handbook</li> </ul>"},{"location":"phil/#community-resources","title":"Community Resources","text":"<ul> <li>BNKops Repository - Documentation and knowledge base</li> <li>Activist Handbook - Movement building resources</li> <li>EFF Surveillance Self-Defense - Digital security guides</li> </ul>"},{"location":"phil/#technical-learning","title":"Technical Learning","text":"<ul> <li>Self-Hosted Awesome List - Open source alternatives</li> <li>Linux Journey - Learn Linux basics</li> <li>Docker Curriculum - Learn containerization</li> </ul> <p>This philosophy document is a living document. Contribute your thoughts, experiences, and improvements through the BNKops documentation platform.</p>"},{"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":"<p>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.</p>"},{"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 <p>*Included in base Changemaker Lite hosting cost \u2020Privacy costs are incalculable but include surveillance, data sales, and community manipulation</p>"},{"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":"<ul> <li>Data Harvesting: Every interaction monitored and stored</li> <li>Behavioral Profiling: Your community mapped and analyzed </li> <li>Third-Party Sales: Your data sold to unknown entities</li> <li>Government Access: Warrantless surveillance through corporate partnerships</li> </ul>"},{"location":"phil/cost-comparison/#political-manipulation","title":"Political Manipulation","text":"<ul> <li>Algorithmic Suppression: Your content reach artificially limited</li> <li>Narrative Control: Corporate interests shape what your community sees</li> <li>Shadow Banning: Activists systematically de-platformed</li> <li>Counter-Intelligence: Your strategies monitored by opposition</li> </ul>"},{"location":"phil/cost-comparison/#movement-disruption","title":"Movement Disruption","text":"<ul> <li>Dependency Creation: Critical infrastructure controlled by adversaries</li> <li>Community Fragmentation: Platforms designed to extract attention, not build power</li> <li>Organizing Interference: Corporate algorithms prioritize engagement over solidarity</li> <li>Cultural Assimilation: Movement culture shaped by corporate values</li> </ul>"},{"location":"phil/cost-comparison/#the-changemaker-advantage","title":"The Changemaker Advantage","text":""},{"location":"phil/cost-comparison/#what-you-get-for-50-150month","title":"What You Get for $50-150/month","text":""},{"location":"phil/cost-comparison/#complete-infrastructure","title":"Complete Infrastructure","text":"<ul> <li>Email System: Unlimited contacts, unlimited sends</li> <li>Database Power: Unlimited records, unlimited complexity</li> <li>Web Presence: Unlimited sites, unlimited traffic</li> <li>Development Environment: Full coding environment with AI assistance</li> <li>Documentation Platform: Beautiful, searchable knowledge base</li> <li>Automation Engine: Connect everything, automate everything</li> <li>File Storage: Unlimited files, unlimited backups</li> </ul>"},{"location":"phil/cost-comparison/#true-ownership","title":"True Ownership","text":"<ul> <li>Your Domain: No corporate branding or limitations</li> <li>Your Data: Complete export capability, no lock-in</li> <li>Your Rules: No terms of service to violate</li> <li>Your Community: No algorithmic manipulation</li> </ul>"},{"location":"phil/cost-comparison/#community-support","title":"Community Support","text":"<ul> <li>Open Documentation: Complete guides and tutorials available</li> <li>Community-Driven Development: Built by and for liberation movements</li> <li>Technical Support: Professional assistance from BNKops cooperative</li> <li>Political Alignment: Technology designed with movement values</li> </ul>"},{"location":"phil/cost-comparison/#the-compound-effect","title":"The Compound Effect","text":""},{"location":"phil/cost-comparison/#year-over-year-savings","title":"Year Over Year Savings","text":"<p>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</p> <p>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</p>"},{"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":"<p>Email Marketing: $____/month Database/CRM: $____/month Website Hosting: $____/month Documentation: $____/month Development Tools: $____/month Automation: $____/month File Storage: $____/month Other SaaS: $____/month </p> <p>Monthly Total: $____ Annual Total: $____ </p> <p>Changemaker Alternative: $50-150/month Your Annual Savings: $____</p>"},{"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":"<p>The money saved by choosing community-controlled technology doesn't disappear\u2014it goes directly back into movement building:</p> <ul> <li>Hire organizers instead of paying corporate executives</li> <li>Fund direct actions instead of funding surveillance infrastructure </li> <li>Support community members instead of enriching shareholders</li> <li>Build lasting power instead of temporary platform dependency</li> </ul>"},{"location":"phil/cost-comparison/#making-the-switch","title":"Making the Switch","text":""},{"location":"phil/cost-comparison/#transition-strategy","title":"Transition Strategy","text":"<p>You don't have to switch everything at once:</p> <ol> <li>Start with documentation - Move your knowledge base to MkDocs</li> <li>Add email infrastructure - Set up Listmonk for newsletters</li> <li>Build your database - Move contact management to NocoDB</li> <li>Automate connections - Use n8n to integrate everything</li> <li>Phase out corporate tools - Cancel subscriptions as you replicate functionality</li> </ol>"},{"location":"phil/cost-comparison/#investment-timeline","title":"Investment Timeline","text":"<ul> <li>Month 1: Initial setup and learning ($150 including setup time)</li> <li>Month 2-3: Data migration and team training ($100/month)</li> <li>Month 4+: Full operation at optimal cost ($50-150/month based on scale)</li> </ul>"},{"location":"phil/cost-comparison/#roi-calculation","title":"ROI Calculation","text":"<p>Most campaigns recover their entire first-year investment in 60-90 days through subscription savings alone.</p> <p>Ready to stop feeding your budget to corporate surveillance? Get started with Changemaker Lite today and take control of your digital infrastructure.</p>"},{"location":"v1/","title":"V1 Documentation (Deprecated)","text":"<p>V1 is Legacy</p> <p>Changemaker Lite V1 is deprecated and no longer actively maintained. These docs are preserved for reference only.</p>"},{"location":"v1/#migrating-to-v2","title":"Migrating to V2","text":"<p>Changemaker Lite V2 is a complete architectural rebuild with significant improvements:</p> <p>A quick test because why not. </p>"},{"location":"v1/#why-upgrade-to-v2","title":"Why Upgrade to V2?","text":"<p>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)</p> <p>Better Performance - Direct database access (no NocoDB middleware) - Redis-backed caching and rate limiting - BullMQ job queues for async operations - Optimized queries with Prisma</p> <p>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</p> <p>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)</p> <p>View complete V2 documentation \u2192</p>"},{"location":"v1/#migration-guide","title":"Migration Guide","text":"<p>Ready to migrate? Follow our step-by-step guide:</p> <p>\u2192 V1 to V2 Migration Guide</p> <p>The migration guide covers:</p> <ol> <li>Breaking Changes - NocoDB \u2192 Prisma, API endpoint changes</li> <li>Data Migration - Export V1 data, transform, import to V2</li> <li>Configuration Changes - Environment variables, service names</li> <li>Feature Parity - V1 vs V2 feature comparison</li> </ol>"},{"location":"v1/#v1-documentation-archive","title":"V1 Documentation Archive","text":"<p>These docs are preserved for existing V1 installations:</p>"},{"location":"v1/#build-guides","title":"Build Guides","text":"<ul> <li>Build Overview</li> <li>Build Server</li> <li>Build Map</li> <li>Build Influence</li> <li>Build Site</li> </ul>"},{"location":"v1/#services","title":"Services","text":"<ul> <li>Services Overview</li> <li>Homepage</li> <li>Code Server</li> <li>MKDocs</li> <li>Listmonk</li> <li>PostgreSQL</li> <li>n8n</li> <li>NocoDB</li> <li>Gitea</li> <li>Map</li> <li>Mini QR</li> </ul>"},{"location":"v1/#configuration","title":"Configuration","text":"<ul> <li>Config Overview</li> <li>Cloudflare</li> <li>MKdocs</li> <li>Code Server</li> <li>Map</li> </ul>"},{"location":"v1/#manuals","title":"Manuals","text":"<ul> <li>Manual Overview</li> <li>Map Manual</li> </ul>"},{"location":"v1/#advanced","title":"Advanced","text":"<ul> <li>Advanced Overview</li> <li>SSH + Tailscale + Ansible</li> <li>SSH + VScode</li> </ul>"},{"location":"v1/#v1-architecture-reference","title":"V1 Architecture (Reference)","text":"<p>V1 used a two-app architecture with NocoDB as the data layer:</p> <p>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</p> <p>Map App (port 3000) - Express.js server with Leaflet.js maps - NocoDB REST API for database operations - QR code generation - Volunteer shift management</p> <p>Shared Infrastructure - NocoDB (data layer) - Redis (sessions, cache, queues) - PostgreSQL (via NocoDB) - Cloudflare tunnels</p>"},{"location":"v1/#support-for-v1","title":"Support for V1","text":"<p>V1 is no longer under active development. We recommend migrating to V2.</p> <p>For critical V1 issues: - Check existing V1 documentation - Review V1 code in <code>/influence</code> and <code>/map</code> directories - Consider migrating to V2</p> <p>Ready to upgrade? Start with the V2 Quick Start Guide \u2192</p>"},{"location":"v1/adv/","title":"Advanced Configurations","text":"<p>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. </p>"},{"location":"v1/adv/ansible/","title":"Setting Up Ansible with Tailscale for Remote Server Management","text":""},{"location":"v1/adv/ansible/#overview","title":"Overview","text":"<p>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.</p> <p>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. </p>"},{"location":"v1/adv/ansible/#what-youll-learn","title":"What You'll Learn","text":"<ul> <li>How to set up Ansible for infrastructure automation</li> <li>How to configure secure remote access using Tailscale</li> <li>How to troubleshoot common SSH and networking issues</li> <li>Why this approach is better than alternatives like Cloudflare Tunnels for simple SSH access</li> </ul>"},{"location":"v1/adv/ansible/#prerequisites","title":"Prerequisites","text":"<ul> <li>Master Node: Your main computer running Ubuntu/Linux (control machine)</li> <li>Target Nodes: Remote servers/ThinkCentres running Ubuntu/Linux</li> <li>Both machines: Must have internet access</li> <li>User Account: Same username on all machines (recommended)</li> </ul>"},{"location":"v1/adv/ansible/#part-1-initial-setup-on-master-node","title":"Part 1: Initial Setup on Master Node","text":""},{"location":"v1/adv/ansible/#1-create-ansible-directory-structure","title":"1. Create Ansible Directory Structure","text":"<pre><code># Create project directory\nmkdir ~/ansible_quickstart\ncd ~/ansible_quickstart\n\n# Create directory structure\nmkdir -p group_vars host_vars roles playbooks\n</code></pre>"},{"location":"v1/adv/ansible/#2-install-ansible","title":"2. Install Ansible","text":"<pre><code>sudo apt update\nsudo apt install ansible\n</code></pre>"},{"location":"v1/adv/ansible/#3-generate-ssh-keys-if-not-already-done","title":"3. Generate SSH Keys (if not already done)","text":"<pre><code># 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</code></pre>"},{"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":"<p>Access each target node physically (monitor + keyboard):</p> <pre><code># 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</code></pre> <p>Note: If you get \"Unit ssh.service could not be found\", you need to install the SSH server first:</p> <pre><code># 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</code></pre> <p>You should see SSH listening on port 22.</p>"},{"location":"v1/adv/ansible/#2-configure-ssh-key-authentication","title":"2. Configure SSH Key Authentication","text":"<pre><code># Create .ssh directory\nmkdir -p ~/.ssh\nchmod 700 ~/.ssh\n\n# Create authorized_keys file\nnano ~/.ssh/authorized_keys\n</code></pre> <p>Paste your public key from the master node, then:</p> <pre><code># Set proper permissions\nchmod 600 ~/.ssh/authorized_keys\n</code></pre>"},{"location":"v1/adv/ansible/#3-configure-ssh-security","title":"3. Configure SSH Security","text":"<pre><code># Edit SSH config\nsudo nano /etc/ssh/sshd_config\n</code></pre> <p>Ensure these lines are uncommented:</p> <pre><code>PubkeyAuthentication yes\nAuthorizedKeysFile .ssh/authorized_keys .ssh/authorized_keys2\n</code></pre> <pre><code># Restart SSH service\nsudo systemctl restart ssh\n</code></pre>"},{"location":"v1/adv/ansible/#4-configure-firewall","title":"4. Configure Firewall","text":"<pre><code># 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</code></pre>"},{"location":"v1/adv/ansible/#part-3-test-local-ssh-connection","title":"Part 3: Test Local SSH Connection","text":"<p>Before proceeding with remote access, test SSH connectivity locally:</p> <pre><code># From master node, test SSH to target\nssh username@<target-local-ip>\n</code></pre> <p>Common Issues and Solutions:</p> <ul> <li>Connection hangs: Check firewall rules (<code>sudo ufw allow ssh</code>)</li> <li>Permission denied: Verify SSH keys and file permissions</li> <li>SSH config errors: Ensure <code>PubkeyAuthentication yes</code> is set</li> </ul>"},{"location":"v1/adv/ansible/#part-4-set-up-tailscale-for-remote-access","title":"Part 4: Set Up Tailscale for Remote Access","text":""},{"location":"v1/adv/ansible/#why-tailscale-over-alternatives","title":"Why Tailscale Over Alternatives","text":"<p>We initially tried Cloudflare Tunnels but encountered complexity with:</p> <ul> <li>DNS routing issues</li> <li>Complex configuration for SSH</li> <li>Same-network testing problems</li> <li>Multiple configuration approaches with varying success</li> </ul> <p>Tailscale is superior because:</p> <ul> <li>Zero configuration mesh networking</li> <li>Works from any network</li> <li>Persistent IP addresses</li> <li>No port forwarding needed</li> <li>Free for personal use</li> </ul>"},{"location":"v1/adv/ansible/#1-install-tailscale-on-master-node","title":"1. Install Tailscale on Master Node","text":"<pre><code># Install Tailscale\ncurl -fsSL https://tailscale.com/install.sh | sh\n\n# Connect to Tailscale network\nsudo tailscale up\n</code></pre> <p>Follow the authentication URL to connect with your Google/Microsoft/GitHub account.</p>"},{"location":"v1/adv/ansible/#2-install-tailscale-on-target-nodes","title":"2. Install Tailscale on Target Nodes","text":"<p>On each target node:</p> <pre><code># Install Tailscale\ncurl -fsSL https://tailscale.com/install.sh | sh\n\n# Connect to Tailscale network\nsudo tailscale up\n</code></pre> <p>Authenticate each device through the provided URL.</p>"},{"location":"v1/adv/ansible/#3-get-tailscale-ip-addresses","title":"3. Get Tailscale IP Addresses","text":"<p>On each machine:</p> <pre><code># Get your Tailscale IP\ntailscale ip -4\n</code></pre> <p>Each device receives a persistent IP like <code>100.x.x.x</code>.</p>"},{"location":"v1/adv/ansible/#part-5-configure-ansible","title":"Part 5: Configure Ansible","text":""},{"location":"v1/adv/ansible/#1-create-inventory-file","title":"1. Create Inventory File","text":"<pre><code># Create inventory.ini\ncd ~/ansible_quickstart\nnano inventory.ini\n</code></pre> <p>Content:</p> <pre><code>[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</code></pre> <p>Replace:</p> <ul> <li><code>100.x.x.x</code> with actual Tailscale IPs</li> <li><code>your-username</code> with your actual username</li> </ul>"},{"location":"v1/adv/ansible/#2-test-ansible-connectivity","title":"2. Test Ansible Connectivity","text":"<pre><code># Test connection to all nodes\nansible all -i inventory.ini -m ping\n</code></pre> <p>Expected output:</p> <pre><code>tc-node1 | SUCCESS => {\n \"changed\": false,\n \"ping\": \"pong\"\n}\n</code></pre>"},{"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":"<pre><code>mkdir -p playbooks\nnano playbooks/info-playbook.yml\n</code></pre> <p>Content:</p> <pre><code>---\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</code></pre>"},{"location":"v1/adv/ansible/#2-run-the-playbook","title":"2. Run the Playbook","text":"<pre><code>ansible-playbook -i inventory.ini playbooks/info-playbook.yml\n</code></pre>"},{"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":"<pre><code>nano playbooks/setup-node.yml\n</code></pre> <p>Content:</p> <pre><code>---\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</code></pre>"},{"location":"v1/adv/ansible/#troubleshooting-guide","title":"Troubleshooting Guide","text":""},{"location":"v1/adv/ansible/#ssh-issues","title":"SSH Issues","text":"<p>Problem: SSH connection hangs</p> <ul> <li>Check firewall: <code>sudo ufw status</code> and <code>sudo ufw allow ssh</code></li> <li>Verify SSH service: <code>sudo systemctl status ssh</code></li> <li>Test local connectivity first</li> </ul> <p>Problem: Permission denied (publickey)</p> <ul> <li>Check SSH key permissions: <code>chmod 600 ~/.ssh/authorized_keys</code></li> <li>Verify home directory permissions: <code>chmod 755 ~/</code></li> <li>Ensure SSH config allows key auth: <code>PubkeyAuthentication yes</code></li> </ul> <p>Problem: Bad owner or permissions on SSH config</p> <pre><code>chmod 600 ~/.ssh/config\n</code></pre>"},{"location":"v1/adv/ansible/#ansible-issues","title":"Ansible Issues","text":"<p>Problem: Host key verification failed</p> <ul> <li>Add to inventory: <code>ansible_host_key_checking=False</code></li> </ul> <p>Problem: Ansible command not found</p> <pre><code>sudo apt install ansible\n</code></pre> <p>Problem: Connection timeouts</p> <ul> <li>Verify Tailscale connectivity: <code>ping <tailscale-ip></code></li> <li>Check if both nodes are connected: <code>tailscale status</code></li> </ul>"},{"location":"v1/adv/ansible/#tailscale-issues","title":"Tailscale Issues","text":"<p>Problem: Can't connect to Tailscale IP</p> <ul> <li>Verify both devices are authenticated: <code>tailscale status</code></li> <li>Check Tailscale is running: <code>sudo systemctl status tailscaled</code></li> <li>Restart Tailscale: <code>sudo tailscale up</code></li> </ul>"},{"location":"v1/adv/ansible/#scaling-to-multiple-nodes","title":"Scaling to Multiple Nodes","text":""},{"location":"v1/adv/ansible/#adding-new-nodes","title":"Adding New Nodes","text":"<ol> <li>Install Tailscale on new node</li> <li>Set up SSH access (repeat Part 2)</li> <li>Add to inventory.ini:</li> </ol> <pre><code>[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</code></pre>"},{"location":"v1/adv/ansible/#group-management","title":"Group Management","text":"<pre><code>[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</code></pre> <p>Run playbooks on specific groups:</p> <pre><code>ansible-playbook -i inventory.ini -l webservers playbook.yml\n</code></pre>"},{"location":"v1/adv/ansible/#best-practices","title":"Best Practices","text":""},{"location":"v1/adv/ansible/#security","title":"Security","text":"<ul> <li>Use SSH keys, not passwords</li> <li>Keep Tailscale client updated</li> <li>Regular security updates via Ansible</li> <li>Use <code>become: yes</code> only when necessary</li> </ul>"},{"location":"v1/adv/ansible/#organization","title":"Organization","text":"<pre><code>ansible_quickstart/\n\u251c\u2500\u2500 inventory.ini\n\u251c\u2500\u2500 group_vars/\n\u251c\u2500\u2500 host_vars/\n\u251c\u2500\u2500 roles/\n\u2514\u2500\u2500 playbooks/\n \u251c\u2500\u2500 info-playbook.yml\n \u251c\u2500\u2500 setup-node.yml\n \u2514\u2500\u2500 maintenance.yml\n</code></pre>"},{"location":"v1/adv/ansible/#monitoring-and-maintenance","title":"Monitoring and Maintenance","text":"<p>Create regular maintenance playbooks:</p> <pre><code>- 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</code></pre>"},{"location":"v1/adv/ansible/#alternative-approaches-we-considered","title":"Alternative Approaches We Considered","text":""},{"location":"v1/adv/ansible/#cloudflare-tunnels","title":"Cloudflare Tunnels","text":"<ul> <li>Pros: Good for web services, handles NAT traversal</li> <li>Cons: Complex SSH setup, DNS routing issues, same-network problems</li> <li>Use case: Better for web applications than SSH access</li> </ul>"},{"location":"v1/adv/ansible/#traditional-vpn","title":"Traditional VPN","text":"<ul> <li>Pros: Full network access</li> <li>Cons: Complex setup, port forwarding required, router configuration</li> <li>Use case: When you control the network infrastructure</li> </ul>"},{"location":"v1/adv/ansible/#ssh-reverse-tunnels","title":"SSH Reverse Tunnels","text":"<ul> <li>Pros: Simple concept</li> <li>Cons: Requires VPS, single point of failure, manual setup</li> <li>Use case: Temporary access or when other methods fail</li> </ul>"},{"location":"v1/adv/ansible/#conclusion","title":"Conclusion","text":"<p>This setup provides:</p> <ul> <li>Reliable remote access from anywhere</li> <li>Secure mesh networking with Tailscale</li> <li>Infrastructure automation with Ansible</li> <li>Easy scaling to multiple nodes</li> <li>No complex networking required</li> </ul> <p>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.</p>"},{"location":"v1/adv/ansible/#quick-reference-commands","title":"Quick Reference Commands","text":"<pre><code># 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</code></pre>"},{"location":"v1/adv/vscode-ssh/","title":"Remote Development with VSCode over Tailscale","text":""},{"location":"v1/adv/vscode-ssh/#overview","title":"Overview","text":"<p>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.</p>"},{"location":"v1/adv/vscode-ssh/#what-youll-learn","title":"What You'll Learn","text":"<ul> <li>How to configure VSCode for remote SSH connections</li> <li>How to set up remote development environments</li> <li>How to manage multiple remote servers efficiently</li> <li>How to troubleshoot common remote development issues</li> <li>Best practices for remote development workflows</li> </ul>"},{"location":"v1/adv/vscode-ssh/#prerequisites","title":"Prerequisites","text":"<ul> <li>Ansible + Tailscale setup completed (see previous guide)</li> <li>VSCode installed on the local machine (master node)</li> <li>Working SSH access to remote servers via Tailscale</li> <li>Tailscale running on both local and remote machines</li> </ul>"},{"location":"v1/adv/vscode-ssh/#verify-prerequisites","title":"Verify Prerequisites","text":"<p>Before starting, verify the setup:</p> <pre><code># Check Tailscale connectivity\ntailscale status\n\n# Test SSH access\nssh <username>@<tailscale-ip>\n\n# Check VSCode is installed\ncode --version\n</code></pre>"},{"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":"<p>Option A: Install Remote Development Pack (Recommended)</p> <ol> <li>Open VSCode</li> <li>Press Ctrl+Shift+X (or Cmd+Shift+X on Mac)</li> <li>Search for \"Remote Development\"</li> <li>Install the Remote Development extension pack by Microsoft</li> </ol> <p>This pack includes:</p> <ul> <li>Remote - SSH</li> <li>Remote - SSH: Editing Configuration Files</li> <li>Remote - Containers</li> <li>Remote - WSL (Windows only)</li> </ul> <p>Option B: Install Individual Extension</p> <ol> <li>Search for \"Remote - SSH\"</li> <li>Install Remote - SSH by Microsoft</li> </ol>"},{"location":"v1/adv/vscode-ssh/#2-verify-installation","title":"2. Verify Installation","text":"<p>After installation, the following should be visible:</p> <ul> <li>Remote Explorer icon in the Activity Bar (left sidebar)</li> <li>\"Remote-SSH\" commands in Command Palette (Ctrl+Shift+P)</li> </ul>"},{"location":"v1/adv/vscode-ssh/#part-2-configure-ssh-connections","title":"Part 2: Configure SSH Connections","text":""},{"location":"v1/adv/vscode-ssh/#1-access-ssh-configuration","title":"1. Access SSH Configuration","text":"<p>Method A: Through VSCode</p> <ol> <li>Press Ctrl+Shift+P to open Command Palette</li> <li>Type \"Remote-SSH: Open SSH Configuration File...\"</li> <li>Select the SSH config file (usually the first option)</li> </ol> <p>Method B: Direct File Editing <pre><code># Edit SSH config file directly\nnano ~/.ssh/config\n</code></pre></p>"},{"location":"v1/adv/vscode-ssh/#2-add-server-configurations","title":"2. Add Server Configurations","text":"<p>Add servers to the SSH config file:</p> <pre><code># 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</code></pre> <p>Configuration Options Explained:</p> <ul> <li><code>Host</code>: Friendly name for the connection</li> <li><code>HostName</code>: Tailscale IP address</li> <li><code>User</code>: Username on the remote server</li> <li><code>IdentityFile</code>: Path to the SSH private key</li> <li><code>ForwardAgent</code>: Enables SSH agent forwarding for Git operations</li> <li><code>ServerAliveInterval</code>: Keeps connection alive (prevents timeouts)</li> <li><code>ServerAliveCountMax</code>: Number of keepalive attempts</li> </ul>"},{"location":"v1/adv/vscode-ssh/#3-set-proper-ssh-key-permissions","title":"3. Set Proper SSH Key Permissions","text":"<pre><code># 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</code></pre>"},{"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":"<ol> <li>Press Ctrl+Shift+P</li> <li>Type \"Remote-SSH: Connect to Host...\"</li> <li>Select the server (e.g., <code>node1</code>)</li> <li>VSCode will open a new window connected to the remote server</li> </ol>"},{"location":"v1/adv/vscode-ssh/#2-connect-via-remote-explorer","title":"2. Connect via Remote Explorer","text":"<ol> <li>Click the Remote Explorer icon in Activity Bar</li> <li>Expand SSH Targets</li> <li>Click the connect icon next to the server name</li> </ol>"},{"location":"v1/adv/vscode-ssh/#3-connect-via-quick-menu","title":"3. Connect via Quick Menu","text":"<ol> <li>Click the remote indicator in bottom-left corner (looks like ><)</li> <li>Select \"Connect to Host...\"</li> <li>Choose the server from the list</li> </ol>"},{"location":"v1/adv/vscode-ssh/#4-first-connection-process","title":"4. First Connection Process","text":"<p>On first connection, VSCode will:</p> <ol> <li>Verify the host key (click \"Continue\" if prompted)</li> <li>Install VSCode Server on the remote machine (automatic)</li> <li>Open a remote window with access to the remote file system</li> </ol> <p>Expected Timeline: - First connection: 1-3 minutes (installs VSCode Server) - Subsequent connections: 10-30 seconds</p>"},{"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":"<p>Once connected:</p> <pre><code># 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</code></pre>"},{"location":"v1/adv/vscode-ssh/#2-install-extensions-on-remote-server","title":"2. Install Extensions on Remote Server","text":"<p>Extensions must be installed separately on the remote server:</p> <p>Essential Development Extensions:</p> <ol> <li>Python (Microsoft) - Python development</li> <li>GitLens (GitKraken) - Enhanced Git capabilities</li> <li>Docker (Microsoft) - Container development</li> <li>Prettier - Code formatting</li> <li>ESLint - JavaScript linting</li> <li>Auto Rename Tag - HTML/XML tag editing</li> </ol> <p>To Install:</p> <ol> <li>Go to Extensions (Ctrl+Shift+X)</li> <li>Find the desired extension</li> <li>Click \"Install in SSH: node1\" (not local install)</li> </ol>"},{"location":"v1/adv/vscode-ssh/#3-configure-git-on-remote-server","title":"3. Configure Git on Remote Server","text":"<pre><code># 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</code></pre>"},{"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":"<p>File Explorer:</p> <ul> <li>Shows remote server's file system</li> <li>Create, edit, delete files directly</li> <li>Drag and drop between local and remote (limited)</li> </ul> <p>File Transfer: <pre><code># 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</code></pre></p>"},{"location":"v1/adv/vscode-ssh/#2-terminal-usage","title":"2. Terminal Usage","text":"<p>Integrated Terminal:</p> <ul> <li>Press Ctrl+` to open terminal</li> <li>Runs directly on remote server</li> <li>Multiple terminals supported</li> <li>Full shell access (bash, zsh, etc.)</li> </ul> <p>Common Remote Terminal Commands: <pre><code># 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</code></pre></p>"},{"location":"v1/adv/vscode-ssh/#3-port-forwarding","title":"3. Port Forwarding","text":"<p>Automatic Port Forwarding: VSCode automatically detects and forwards common development ports.</p> <p>Manual Port Forwarding:</p> <ol> <li>Open Ports tab in terminal panel</li> <li>Click \"Forward a Port\"</li> <li>Enter port number (e.g., 3000, 8080, 5000)</li> <li>Access via <code>http://localhost:port</code> on the local machine</li> </ol> <p>Example: Web Development <pre><code># 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</code></pre></p>"},{"location":"v1/adv/vscode-ssh/#4-debugging-remote-applications","title":"4. Debugging Remote Applications","text":"<p>Python Debugging: <pre><code>// .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</code></pre></p> <p>Node.js Debugging: <pre><code>// .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</code></pre></p>"},{"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":"<p>Create remote-specific settings:</p> <pre><code>// .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</code></pre>"},{"location":"v1/adv/vscode-ssh/#2-multi-server-management","title":"2. Multi-Server Management","text":"<p>Switch Between Servers:</p> <ol> <li>Click remote indicator (bottom-left)</li> <li>Select \"Connect to Host...\"</li> <li>Choose a different server</li> </ol> <p>Compare Files Across Servers:</p> <ol> <li>Open file from server A</li> <li>Connect to server B in new window</li> <li>Open corresponding file</li> <li>Use \"Compare with...\" command</li> </ol>"},{"location":"v1/adv/vscode-ssh/#3-sync-configuration","title":"3. Sync Configuration","text":"<p>Settings Sync:</p> <ol> <li>Enable Settings Sync in VSCode</li> <li>Settings, extensions, and keybindings sync to remote</li> <li>Consistent experience across all servers</li> </ol>"},{"location":"v1/adv/vscode-ssh/#part-7-project-specific-setups","title":"Part 7: Project-Specific Setups","text":""},{"location":"v1/adv/vscode-ssh/#1-python-development","title":"1. Python Development","text":"<pre><code># 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</code></pre> <p>VSCode Python Configuration: <pre><code>// .vscode/settings.json\n{\n \"python.defaultInterpreterPath\": \"./venv/bin/python\",\n \"python.linting.enabled\": true,\n \"python.linting.pylintEnabled\": true\n}\n</code></pre></p>"},{"location":"v1/adv/vscode-ssh/#2-nodejs-development","title":"2. Node.js Development","text":"<pre><code># 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</code></pre>"},{"location":"v1/adv/vscode-ssh/#3-docker-development","title":"3. Docker Development","text":"<pre><code># 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</code></pre> <p>VSCode Docker Integration:</p> <ul> <li>Install Docker extension on remote</li> <li>Right-click Dockerfile \u2192 \"Build Image\"</li> <li>Manage containers from VSCode interface</li> </ul>"},{"location":"v1/adv/vscode-ssh/#part-8-troubleshooting-guide","title":"Part 8: Troubleshooting Guide","text":""},{"location":"v1/adv/vscode-ssh/#common-connection-issues","title":"Common Connection Issues","text":"<p>Problem: \"Could not establish connection to remote host\"</p> <p>Solutions: <pre><code># 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</code></pre></p> <p>Problem: \"Permission denied (publickey)\"</p> <p>Solutions: <pre><code># 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</code></pre></p> <p>Problem: \"Host key verification failed\"</p> <p>Solutions: <pre><code># 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</code></pre></p>"},{"location":"v1/adv/vscode-ssh/#vscode-specific-issues","title":"VSCode-Specific Issues","text":"<p>Problem: Extensions not working on remote</p> <p>Solutions:</p> <ol> <li>Install extensions specifically for the remote server</li> <li>Check extension compatibility with remote development</li> <li>Reload VSCode window: Ctrl+Shift+P \u2192 \"Developer: Reload Window\"</li> </ol> <p>Problem: Slow performance</p> <p>Solutions: - Use <code>.vscode/settings.json</code> to exclude large directories: <pre><code>{\n \"files.watcherExclude\": {\n \"**/node_modules/**\": true,\n \"**/.git/objects/**\": true,\n \"**/dist/**\": true\n }\n}\n</code></pre></p> <p>Problem: Terminal not starting</p> <p>Solutions: <pre><code># 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</code></pre></p>"},{"location":"v1/adv/vscode-ssh/#network-and-performance-issues","title":"Network and Performance Issues","text":"<p>Problem: Connection timeouts</p> <p>Solutions: Add to SSH config: <pre><code>ServerAliveInterval 60\nServerAliveCountMax 3\nTCPKeepAlive yes\n</code></pre></p> <p>Problem: File transfer slow</p> <p>Solutions: - Use <code>.vscodeignore</code> to exclude unnecessary files - Compress large files before transfer - Use <code>rsync</code> for large file operations: <pre><code>rsync -avz --progress localdir/ <username>@<tailscale-ip>:remotedir/\n</code></pre></p>"},{"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":"<ol> <li>Use SSH keys, never passwords</li> <li>Keep SSH agent secure</li> <li>Regular security updates on remote servers</li> <li>Use VSCode's secure connection verification</li> </ol>"},{"location":"v1/adv/vscode-ssh/#performance-optimization","title":"Performance Optimization","text":"<ol> <li> <p>Exclude unnecessary files: <pre><code>// .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</code></pre></p> </li> <li> <p>Use remote workspace for large projects</p> </li> <li>Close unnecessary windows and extensions</li> <li>Use efficient development workflows</li> </ol>"},{"location":"v1/adv/vscode-ssh/#development-workflow","title":"Development Workflow","text":"<ol> <li> <p>Use version control effectively: <pre><code># Always work in Git repositories\ngit status\ngit add .\ngit commit -m \"feature: add new functionality\"\ngit push origin main\n</code></pre></p> </li> <li> <p>Environment separation: <pre><code># Development\nssh node1\ncd /home/<username>/dev-projects\n\n# Production\nssh node2\ncd /opt/production-apps\n</code></pre></p> </li> <li> <p>Backup important work: <pre><code># Regular backups via Git\ngit push origin main\n\n# Or manual backup\nscp -r <username>@<tailscale-ip>:/important/project ./backup/\n</code></pre></p> </li> </ol>"},{"location":"v1/adv/vscode-ssh/#part-10-team-collaboration","title":"Part 10: Team Collaboration","text":""},{"location":"v1/adv/vscode-ssh/#shared-development-servers","title":"Shared Development Servers","text":"<p>SSH Config for Team: <pre><code># 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</code></pre></p>"},{"location":"v1/adv/vscode-ssh/#project-structure","title":"Project Structure","text":"<pre><code>/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</code></pre>"},{"location":"v1/adv/vscode-ssh/#access-management","title":"Access Management","text":"<pre><code># 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</code></pre>"},{"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":"<pre><code># 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</code></pre>"},{"location":"v1/adv/vscode-ssh/#ssh-connection-quick-test","title":"SSH Connection Quick Test","text":"<pre><code># 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</code></pre>"},{"location":"v1/adv/vscode-ssh/#port-forwarding-commands","title":"Port Forwarding Commands","text":"<pre><code># 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</code></pre>"},{"location":"v1/adv/vscode-ssh/#conclusion","title":"Conclusion","text":"<p>This remote development setup provides:</p> <ul> <li>Full development environment on remote servers</li> <li>Seamless file access and editing capabilities</li> <li>Integrated debugging and terminal access</li> <li>Port forwarding for web development</li> <li>Extension ecosystem available remotely</li> <li>Secure connections through Tailscale network</li> </ul> <p>The combination of VSCode Remote Development with Tailscale networking creates a powerful, flexible development environment that works from anywhere while maintaining security and performance.</p> <p>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.</p>"},{"location":"v1/build/","title":"Getting Started","text":"<p>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.</p> <p>This documentation is broken into a few sections, which you can see in the navigation bar to the left: </p> <ul> <li>Build: Instructions on how to build the cm-lite on your own hardware </li> <li>Services: Overview of all the services that are installed when you install cm-lite</li> <li>Configuration: Information on how to configure all the services that you install in cm-lite</li> <li>Manuals: Manuals on how to use the applications inside cm-lite (with videos!)</li> </ul> <p>Of course, everything is also searachable, so if you want to find something specific, just use the search bar at the top right.</p> <p>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.</p>"},{"location":"v1/build/#quick-start","title":"Quick Start","text":""},{"location":"v1/build/#build-changemaker-lite","title":"Build Changemaker-Lite","text":"<pre><code># Clone the repository\ngit clone https://gitea.bnkops.com/admin/changemaker.lite\ncd changemaker.lite\n</code></pre> <p>Cloudflare Credentials</p> <p>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</p> <pre><code># Configure environment (creates .env file)\n./config.sh\n</code></pre> <pre><code># Start all services\ndocker compose up -d\n</code></pre>"},{"location":"v1/build/#optional-site-builld","title":"Optional - Site Builld","text":"<p>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. </p>"},{"location":"v1/build/#deploy","title":"Deploy","text":"<p>Cloudflare</p> <p>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. </p> <p>For secure public access, use the production deployment script:</p> <pre><code>./start-production.sh\n</code></pre>"},{"location":"v1/build/#map","title":"Map","text":"<p>Map is the canvassing application that is custom view of nocodb data. Map is best built after production deployment to reduce duplicate build efforts.</p> <p>Instructions on how to build the map are available in the map manual in the build directory.</p>"},{"location":"v1/build/#quick-start-for-map","title":"Quick Start for Map","text":"<p>Get your NocoDB API token and URL, update the .env file in the map directory, and then run:</p> <p><pre><code>cd map\nchmod +x build-nocodb.sh # builds the nocodb tables\n./build-nocodb.sh\n</code></pre> Copy the urls of the newly created nocodb views and update the .env file in the map directory with them, and then run:</p> <pre><code>cd map\ndocker compose up -d\n</code></pre> <p>You Map instance will be available at http://localhost:3000 or on the domain you set up during production deployment.</p>"},{"location":"v1/build/#why-changemaker-lite","title":"Why Changemaker Lite?","text":"<p>Before we dive into the technical setup, let's be clear about what you're doing here:</p> <p>The Reality</p> <p>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.</p>"},{"location":"v1/build/#what-youre-getting","title":"What You're Getting","text":"<ul> <li>Data Sovereignty: Your data stays on your servers</li> <li>Cost Savings: $50/month instead of $2,000+/month for corporate solutions </li> <li>Community Control: Technology that serves movements, not shareholders</li> <li>Trans Liberation: Tools built with radical politics and care</li> </ul>"},{"location":"v1/build/#what-youre-leaving-behind","title":"What You're Leaving Behind","text":"<ul> <li>\u274c Corporate surveillance and data extraction</li> <li>\u274c Escalating subscription fees and vendor lock-in</li> <li>\u274c Algorithmic manipulation of your community</li> <li>\u274c Terms of service that can silence you anytime</li> </ul>"},{"location":"v1/build/#system-requirements","title":"System Requirements","text":""},{"location":"v1/build/#operating-system","title":"Operating System","text":"<ul> <li>Ubuntu 24.04 LTS (Noble Numbat) - Recommended and tested</li> </ul> <p>Getting Started on Ubuntu</p> <p>Want some help getting started with a baseline buildout for a Ubuntu server? You can use our BNKops Server Build Script</p> <ul> <li>Other Linux distributions with systemd support</li> <li>WSL2 on Windows (limited functionality)</li> <li>Mac OS</li> </ul> <p>New to Linux?</p> <p>Consider Linux Mint - it looks like Windows but opens the door to true digital freedom.</p>"},{"location":"v1/build/#hardware-requirements","title":"Hardware Requirements","text":"<ul> <li>CPU: 2+ cores (4+ recommended)</li> <li>RAM: 4GB minimum (8GB recommended) </li> <li>Storage: 20GB+ available disk space</li> <li>Network: Stable internet connection</li> </ul> <p>Cloud Hosting</p> <p>You can run this on a VPS from providers like Hetzner, DigitalOcean, or Linode for ~$20/month.</p>"},{"location":"v1/build/#software-prerequisites","title":"Software Prerequisites","text":"<p>Ensure the following software is installed on your system. The BNKops Server Build Script can help set these up if you're on Ubuntu. </p> <ol> <li>Docker Engine (24.0+)</li> </ol> <pre><code># 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</code></pre> <ol> <li>Docker Compose (v2.20+)</li> </ol> <pre><code># Verify Docker Compose v2 is installed\ndocker compose version\n</code></pre> <ol> <li>Essential Tools</li> </ol> <pre><code># Install required packages\nsudo apt update\nsudo apt install -y git curl jq openssl\n</code></pre>"},{"location":"v1/build/#installation","title":"Installation","text":""},{"location":"v1/build/#1-clone-repository","title":"1. Clone Repository","text":"<pre><code>git clone https://gitea.bnkops.com/admin/changemaker.lite\ncd changemaker.lite\n</code></pre>"},{"location":"v1/build/#2-run-configuration-wizard","title":"2. Run Configuration Wizard","text":"<p>The <code>config.sh</code> script will guide you through the initial setup:</p> <pre><code>./config.sh\n</code></pre> <p>This wizard will:</p> <ul> <li>\u2705 Create a <code>.env</code> file with secure defaults</li> <li>\u2705 Scan for available ports to avoid conflicts</li> <li>\u2705 Set up your domain configuration</li> <li>\u2705 Generate secure passwords for databases</li> <li>\u2705 Configure Cloudflare credentials (optional)</li> <li>\u2705 Update all configuration files with your settings</li> </ul>"},{"location":"v1/build/#configuration-options","title":"Configuration Options","text":"<p>During setup, you'll be prompted for:</p> <ol> <li>Domain Name: Your primary domain (e.g., <code>example.com</code>)</li> <li>Cloudflare Settings (optional):</li> <li>API Token</li> <li>Zone ID</li> <li>Account ID</li> <li>Admin Credentials:</li> <li>Listmonk admin email and password</li> <li>n8n admin email and password</li> </ol>"},{"location":"v1/build/#3-start-services","title":"3. Start Services","text":"<p>Launch all services with Docker Compose:</p> <pre><code>docker compose up -d\n</code></pre> <p>Wait for services to initialize (first run may take 5-10 minutes):</p> <pre><code># Watch container status\ndocker compose ps\n\n# View logs\ndocker compose logs -f\n</code></pre>"},{"location":"v1/build/#4-verify-installation","title":"4. Verify Installation","text":"<p>Check that all services are running:</p> <pre><code>docker compose ps\n</code></pre> <p>Expected output should show all services as \"Up\":</p> <ul> <li>code-server-changemaker</li> <li>listmonk_app</li> <li>listmonk_db</li> <li>mkdocs-changemaker</li> <li>mkdocs-site-server-changemaker</li> <li>n8n-changemaker</li> <li>nocodb</li> <li>root_db</li> <li>homepage-changemaker</li> <li>gitea_changemaker</li> <li>gitea_mysql_changemaker</li> <li>mini-qr</li> </ul>"},{"location":"v1/build/#local-access","title":"Local Access","text":"<p>Once services are running, access them locally:</p>"},{"location":"v1/build/#homepage-dashboard","title":"\ud83c\udfe0 Homepage Dashboard","text":"<ul> <li>URL: http://localhost:3010 </li> <li>Purpose: Central hub for all services </li> <li>Features: Service status, quick links, monitoring</li> </ul>"},{"location":"v1/build/#development-tools","title":"\ud83d\udcbb Development Tools","text":"<ul> <li>Code Server: http://localhost:8888 \u2014 VS Code in browser </li> <li>Gitea: http://localhost:3030 \u2014 Git repository management </li> <li>MkDocs Dev: http://localhost:4000 \u2014 Live documentation preview </li> <li>MkDocs Prod: http://localhost:4001 \u2014 Built documentation</li> </ul>"},{"location":"v1/build/#communication","title":"\ud83d\udce7 Communication","text":"<ul> <li>Listmonk: http://localhost:9000 \u2014 Email campaigns Login with credentials set during configuration</li> </ul>"},{"location":"v1/build/#automation-data","title":"\ud83d\udd04 Automation & Data","text":"<ul> <li>n8n: http://localhost:5678 \u2014 Workflow automation Login with credentials set during configuration </li> <li>NocoDB: http://localhost:8090 \u2014 No-code database</li> </ul>"},{"location":"v1/build/#interactive-tools","title":"\ud83d\udee0\ufe0f Interactive Tools","text":"<ul> <li>Mini QR: http://localhost:8089 \u2014 QR code generator</li> </ul>"},{"location":"v1/build/#map_1","title":"Map","text":"<p>Map</p> <p>Map is the canvassing application that is custom view of nocodb data. Map is best built after production deployment to reduce duplicate build efforts. </p>"},{"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":"<p>For secure public access, use the production deployment script:</p> <pre><code>./start-production.sh\n</code></pre> <p>This script will:</p> <ol> <li>Install and configure <code>cloudflared</code></li> <li>Create a Cloudflare tunnel</li> <li>Set up DNS records automatically</li> <li>Configure access policies</li> <li>Create a systemd service for persistence</li> </ol>"},{"location":"v1/build/#what-happens-during-production-setup","title":"What Happens During Production Setup","text":"<ol> <li>Cloudflare Authentication: Browser-based login to Cloudflare</li> <li>Tunnel Creation: Secure tunnel named <code>changemaker-lite</code></li> <li>DNS Configuration: Automatic CNAME records for all services</li> <li>Access Policies: Email-based authentication for sensitive services</li> <li>Service Installation: Systemd service for automatic startup</li> </ol>"},{"location":"v1/build/#production-urls","title":"Production URLs","text":"<p>After successful deployment, services will be available at:</p> <p>Public Services:</p> <ul> <li><code>https://yourdomain.com</code> - Main documentation site</li> <li><code>https://listmonk.yourdomain.com</code> - Email campaigns</li> <li><code>https://docs.yourdomain.com</code> - Documentation preview</li> <li><code>https://n8n.yourdomain.com</code> - Automation platform</li> <li><code>https://db.yourdomain.com</code> - NocoDB</li> <li><code>https://git.yourdomain.com</code> - Gitea</li> <li><code>https://map.yourdomain.com</code> - Map viewer</li> <li><code>https://qr.yourdomain.com</code> - QR generator</li> </ul> <p>Protected Services (require authentication):</p> <ul> <li><code>https://homepage.yourdomain.com</code> - Dashboard</li> <li><code>https://code.yourdomain.com</code> - Code Server</li> </ul>"},{"location":"v1/build/#configuration-management","title":"Configuration Management","text":""},{"location":"v1/build/#environment-variables","title":"Environment Variables","text":"<p>Key settings in <code>.env</code> file:</p> <pre><code># 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</code></pre>"},{"location":"v1/build/#reconfigure-services","title":"Reconfigure Services","text":"<p>To update configuration:</p> <pre><code># Re-run configuration wizard\n./config.sh\n\n# Restart services\ndocker compose down && docker compose up -d\n</code></pre>"},{"location":"v1/build/#common-tasks","title":"Common Tasks","text":""},{"location":"v1/build/#service-management","title":"Service Management","text":"<pre><code># 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</code></pre>"},{"location":"v1/build/#backup-data","title":"Backup Data","text":"<pre><code># 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</code></pre>"},{"location":"v1/build/#update-services","title":"Update Services","text":"<pre><code># Pull latest images\ndocker compose pull\n\n# Recreate containers with new images\ndocker compose up -d\n</code></pre>"},{"location":"v1/build/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v1/build/#port-conflicts","title":"Port Conflicts","text":"<p>If services fail to start due to port conflicts:</p> <ol> <li>Check which ports are in use:</li> </ol> <pre><code>sudo ss -tulpn | grep LISTEN\n</code></pre> <ol> <li>Re-run configuration to get new ports:</li> </ol> <pre><code>./config.sh\n</code></pre> <ol> <li>Or manually edit <code>.env</code> file and change conflicting ports</li> </ol>"},{"location":"v1/build/#permission-issues","title":"Permission Issues","text":"<p>Fix permission problems:</p> <pre><code># 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</code></pre>"},{"location":"v1/build/#service-wont-start","title":"Service Won't Start","text":"<p>Debug service issues:</p> <pre><code># 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</code></pre>"},{"location":"v1/build/#cloudflare-tunnel-issues","title":"Cloudflare Tunnel Issues","text":"<pre><code># 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</code></pre>"},{"location":"v1/build/#next-steps","title":"Next Steps","text":"<p>Now that your Changemaker Lite instance is running:</p> <ol> <li>Set up Listmonk - Configure SMTP and create your first campaign</li> <li>Create workflows - Build automations in n8n</li> <li>Import data - Set up your NocoDB databases</li> <li>Configure map - Add location data for the map viewer</li> <li>Write documentation - Start creating content in MkDocs</li> <li>Set up Git - Initialize repositories in Gitea</li> </ol>"},{"location":"v1/build/#getting-help","title":"Getting Help","text":"<ul> <li>Check the Services documentation for detailed guides</li> <li>Review container logs for specific error messages</li> <li>Ensure all prerequisites are properly installed</li> <li>Verify your domain DNS settings for production deployment</li> </ul>"},{"location":"v1/build/influence/","title":"Influence Build Guide","text":"<p>Influence is BNKops campaign tool for connecting Alberta residents with their elected representatives across all levels of government.</p> <p>Complete Configuration</p> <p>For detailed configuration, usage instructions, and troubleshooting, see the main Influence README.</p> <p>Email Testing</p> <p>The application includes MailHog integration for safe email testing during development. All test emails are caught locally and never sent to actual representatives.</p>"},{"location":"v1/build/influence/#prerequisites","title":"Prerequisites","text":"<ul> <li>Docker and Docker Compose installed</li> <li>NocoDB instance with API access</li> <li>SMTP email configuration (or use MailHog for testing)</li> <li>Domain name (optional but recommended for production)</li> </ul>"},{"location":"v1/build/influence/#quick-build-process","title":"Quick Build Process","text":""},{"location":"v1/build/influence/#1-get-nocodb-api-token","title":"1. Get NocoDB API Token","text":"<ol> <li>Login to your NocoDB instance</li> <li>Click user icon \u2192 Account Settings \u2192 API Tokens</li> <li>Create new token with read/write permissions</li> <li>Copy the token for the next step</li> </ol>"},{"location":"v1/build/influence/#2-configure-environment","title":"2. Configure Environment","text":"<p>Navigate to the influence directory and create your environment file:</p> <pre><code>cd influence\ncp example.env .env\n</code></pre> <p>Edit the <code>.env</code> file with your configuration:</p>"},{"location":"v1/build/influence/#development-mode-configuration","title":"Development Mode Configuration","text":"<p>For development and testing, use MailHog to catch emails:</p> <pre><code># 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</code></pre>"},{"location":"v1/build/influence/#3-auto-create-database-structure","title":"3. Auto-Create Database Structure","text":"<p>Run the build script to create required NocoDB tables:</p> <pre><code>chmod +x scripts/build-nocodb.sh\n./scripts/build-nocodb.sh\n</code></pre> <p>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</p>"},{"location":"v1/build/influence/#4-build-and-deploy","title":"4. Build and Deploy","text":"<p>Build the Docker image and start the application:</p> <pre><code># Build the Docker image\ndocker compose build\n\n# Start the application (includes MailHog in development)\ndocker compose up -d\n</code></pre>"},{"location":"v1/build/influence/#verify-installation","title":"Verify Installation","text":"<ol> <li> <p>Check container status: <pre><code>docker compose ps\n</code></pre></p> </li> <li> <p>View logs: <pre><code>docker compose logs -f app\n</code></pre></p> </li> <li> <p>Access the application:</p> </li> <li>Main App: http://localhost:3333</li> <li>Admin Panel: http://localhost:3333/admin.html</li> <li>Email Testing (dev): http://localhost:3333/email-test.html</li> <li>MailHog UI (dev): http://localhost:8025</li> </ol>"},{"location":"v1/build/influence/#initial-setup","title":"Initial Setup","text":""},{"location":"v1/build/influence/#1-create-admin-user","title":"1. Create Admin User","text":"<p>Access the admin panel at <code>/admin.html</code> and create your first administrator account.</p>"},{"location":"v1/build/influence/#2-create-your-first-campaign","title":"2. Create Your First Campaign","text":"<ol> <li>Login to the admin panel</li> <li>Click \"Create Campaign\"</li> <li>Configure basic settings:</li> <li>Campaign title and description</li> <li>Email subject and body template</li> <li>Upload cover photo (optional)</li> <li>Set campaign options:</li> <li>\u2705 Allow SMTP Email - Enable server-side sending</li> <li>\u2705 Allow Mailto Link - Enable browser-based mailto</li> <li>\u2705 Collect User Info - Request name and email</li> <li>\u2705 Show Email Count - Display engagement metrics</li> <li>\u2705 Allow Email Editing - Let users customize message</li> <li>Select target government levels (Federal, Provincial, Municipal, School Board)</li> <li>Set status to Active to make campaign public</li> <li>Click \"Create Campaign\"</li> </ol>"},{"location":"v1/build/influence/#3-test-representative-lookup","title":"3. Test Representative Lookup","text":"<ol> <li>Visit the homepage</li> <li>Enter an Alberta postal code (e.g., T5N4B8)</li> <li>View representatives at all government levels</li> <li>Test email sending functionality</li> </ol>"},{"location":"v1/build/influence/#development-workflow","title":"Development Workflow","text":""},{"location":"v1/build/influence/#email-testing-interface","title":"Email Testing Interface","text":"<p>Access the email testing interface at <code>/email-test.html</code> (requires admin login):</p> <p>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</p>"},{"location":"v1/build/influence/#mailhog-web-interface","title":"MailHog Web Interface","text":"<p>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</p>"},{"location":"v1/build/influence/#switching-to-production","title":"Switching to Production","text":"<p>When ready to deploy to production:</p> <ol> <li> <p>Update <code>.env</code> with production SMTP settings: <pre><code>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</code></pre></p> </li> <li> <p>Restart the application: <pre><code>docker compose restart\n</code></pre></p> </li> </ol>"},{"location":"v1/build/influence/#key-features","title":"Key Features","text":""},{"location":"v1/build/influence/#representative-lookup","title":"Representative Lookup","text":"<ul> <li>Search by Alberta postal code (T prefix)</li> <li>Display federal MPs, provincial MLAs, municipal representatives</li> <li>Smart caching with NocoDB for fast performance</li> <li>Graceful fallback to Represent API when cache unavailable</li> </ul>"},{"location":"v1/build/influence/#campaign-system","title":"Campaign System","text":"<ul> <li>Create unlimited advocacy campaigns</li> <li>Upload cover photos for campaign pages</li> <li>Customizable email templates</li> <li>Optional user information collection</li> <li>Toggle email count display for engagement metrics</li> <li>Multi-level government targeting</li> </ul>"},{"location":"v1/build/influence/#email-integration","title":"Email Integration","text":"<ul> <li>SMTP email sending with delivery confirmation</li> <li>Mailto link support for browser-based email</li> <li>Comprehensive email logging</li> <li>Rate limiting for API protection</li> <li>Test mode for safe development</li> </ul>"},{"location":"v1/build/influence/#api-endpoints","title":"API Endpoints","text":""},{"location":"v1/build/influence/#public-endpoints","title":"Public Endpoints","text":"<ul> <li><code>GET /</code> - Homepage with representative lookup</li> <li><code>GET /campaign/:slug</code> - Individual campaign page</li> <li><code>GET /api/public/campaigns</code> - List active campaigns</li> <li><code>GET /api/representatives/by-postal/:postalCode</code> - Find representatives</li> <li><code>POST /api/emails/send</code> - Send campaign email</li> </ul>"},{"location":"v1/build/influence/#admin-endpoints-authentication-required","title":"Admin Endpoints (Authentication Required)","text":"<ul> <li><code>GET /admin.html</code> - Campaign management dashboard</li> <li><code>GET /email-test.html</code> - Email testing interface</li> <li><code>POST /api/emails/preview</code> - Preview email without sending</li> <li><code>POST /api/emails/test</code> - Send test email</li> <li><code>GET /api/test-smtp</code> - Test SMTP connection</li> </ul>"},{"location":"v1/build/influence/#maintenance-commands","title":"Maintenance Commands","text":""},{"location":"v1/build/influence/#update-application","title":"Update Application","text":"<pre><code>docker compose down\ngit pull origin main\ndocker compose build\ndocker compose up -d\n</code></pre>"},{"location":"v1/build/influence/#development-mode","title":"Development Mode","text":"<pre><code>cd app\nnpm install\nnpm run dev\n</code></pre>"},{"location":"v1/build/influence/#view-logs","title":"View Logs","text":"<pre><code># Follow application logs\ndocker compose logs -f app\n\n# View MailHog logs (development)\ndocker compose logs -f mailhog\n</code></pre>"},{"location":"v1/build/influence/#database-backup","title":"Database Backup","text":"<pre><code># Backup is handled through NocoDB\n# Access NocoDB admin panel to export tables\n</code></pre>"},{"location":"v1/build/influence/#health-check","title":"Health Check","text":"<pre><code>curl http://localhost:3333/api/health\n</code></pre>"},{"location":"v1/build/influence/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v1/build/influence/#nocodb-connection-issues","title":"NocoDB Connection Issues","text":"<ul> <li>Verify <code>NOCODB_API_URL</code> and <code>NOCODB_API_TOKEN</code> in <code>.env</code></li> <li>Run <code>./scripts/build-nocodb.sh</code> to ensure tables exist</li> <li>Application works without NocoDB (API fallback mode)</li> </ul>"},{"location":"v1/build/influence/#email-not-sending","title":"Email Not Sending","text":"<ul> <li>In development: Check MailHog UI at http://localhost:8025</li> <li>Verify SMTP credentials in <code>.env</code></li> <li>Use <code>/email-test.html</code> interface for diagnostics</li> <li>Check email logs via admin panel</li> <li>Review <code>docker compose logs -f app</code> for errors</li> </ul>"},{"location":"v1/build/influence/#no-representatives-found","title":"No Representatives Found","text":"<ul> <li>Ensure postal code starts with 'T' (Alberta only)</li> <li>Try different postal code format (remove spaces)</li> <li>Check Represent API status: <code>curl http://localhost:3333/api/test-represent</code></li> <li>Review application logs for API errors</li> </ul>"},{"location":"v1/build/influence/#campaign-not-appearing","title":"Campaign Not Appearing","text":"<ul> <li>Verify campaign status is set to \"Active\"</li> <li>Check campaign configuration in admin panel</li> <li>Clear browser cache and reload homepage</li> <li>Review console for JavaScript errors</li> </ul>"},{"location":"v1/build/influence/#production-deployment","title":"Production Deployment","text":""},{"location":"v1/build/influence/#environment-configuration","title":"Environment Configuration","text":"<pre><code>NODE_ENV=production\nEMAIL_TEST_MODE=false\nPORT=3333\n\n# Use production SMTP settings\nSMTP_HOST=smtp.your-provider.com\nSMTP_PORT=587\nSMTP_SECURE=false\nSMTP_USER=your-production-email@domain.com\nSMTP_PASS=your-production-password\n</code></pre>"},{"location":"v1/build/influence/#docker-production","title":"Docker Production","text":"<pre><code># 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</code></pre>"},{"location":"v1/build/influence/#monitoring","title":"Monitoring","text":"<ul> <li>Health check endpoint: <code>/api/health</code></li> <li>Email logs via admin panel</li> <li>NocoDB integration status in logs</li> <li>Rate limiting metrics in application logs</li> </ul>"},{"location":"v1/build/influence/#security-considerations","title":"Security Considerations","text":"<ul> <li>\ud83d\udd12 Always use strong passwords for admin accounts</li> <li>\ud83d\udd12 Enable HTTPS in production (use reverse proxy)</li> <li>\ud83d\udd12 Rotate SMTP credentials regularly</li> <li>\ud83d\udd12 Monitor email logs for suspicious activity</li> <li>\ud83d\udd12 Set appropriate rate limits based on expected traffic</li> <li>\ud83d\udd12 Keep NocoDB API tokens secure and rotate periodically</li> <li>\ud83d\udd12 Use <code>EMAIL_TEST_MODE=false</code> only in production</li> </ul>"},{"location":"v1/build/influence/#support","title":"Support","text":"<p>For detailed configuration, troubleshooting, and usage instructions, see: - Main Influence README - Campaign Settings Guide - Files Explainer</p>"},{"location":"v1/build/map/","title":"Map Build Guide","text":"<p>Map is BNKops canvassing application built for community organizing and door-to-door canvassing.</p> <p>Complete Configuration</p> <p>For detailed configuration, usage instructions, and troubleshooting, see the Map Configuration Guide.</p> <p>Clean NocoDB</p> <p>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. </p>"},{"location":"v1/build/map/#prerequisites","title":"Prerequisites","text":"<ul> <li>Docker and Docker Compose installed</li> <li>NocoDB instance with API access</li> <li>Domain name (optional but recommended for production)</li> </ul>"},{"location":"v1/build/map/#quick-build-process","title":"Quick Build Process","text":""},{"location":"v1/build/map/#1-get-nocodb-api-token","title":"1. Get NocoDB API Token","text":"<ol> <li>Login to your NocoDB instance</li> <li>Click user icon \u2192 Account Settings \u2192 API Tokens</li> <li>Create new token with read/write permissions</li> <li>Copy the token for the next step</li> </ol>"},{"location":"v1/build/map/#2-configure-environment","title":"2. Configure Environment","text":"<p>Edit the <code>.env</code> file in the <code>map/</code> directory:</p> <pre><code>cd map\n</code></pre> <p>Update your <code>.env</code> file with your NocoDB details, specifically the instance and api token:</p> <pre><code>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</code></pre>"},{"location":"v1/build/map/#3-auto-create-database-structure","title":"3. Auto-Create Database Structure","text":"<p>Run the build script to create required tables:</p> <pre><code>chmod +x build-nocodb.sh\n./build-nocodb.sh\n</code></pre> <p>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</p>"},{"location":"v1/build/map/#4-get-table-urls","title":"4. Get Table URLs","text":"<p>After the script completes:</p> <ol> <li>Login to your NocoDB instance</li> <li>Navigate to your project (\"Map Viewer Project\")</li> <li>Copy the view URLs for each table from your browser address bar</li> <li>URLs should look like: <code>https://your-nocodb.com/dashboard/#/nc/project-id/table-id</code></li> </ol>"},{"location":"v1/build/map/#5-update-environment-with-urls","title":"5. Update Environment with URLs","text":"<p>Edit your <code>.env</code> file and add the table URLs:</p> <pre><code># 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</code></pre>"},{"location":"v1/build/map/#6-build-and-deploy","title":"6. Build and Deploy","text":"<p>Build the Docker image and start the application:</p> <pre><code># Build the Docker image\ndocker-compose build\n\n# Start the application\ndocker-compose up -d\n</code></pre>"},{"location":"v1/build/map/#verify-installation","title":"Verify Installation","text":"<ol> <li> <p>Check container status: <pre><code>docker-compose ps\n</code></pre></p> </li> <li> <p>View logs: <pre><code>docker-compose logs -f map-viewer\n</code></pre></p> </li> <li> <p>Access the application at <code>http://localhost:3000</code></p> </li> </ol>"},{"location":"v1/build/map/#quick-start","title":"Quick Start","text":"<ol> <li>Login: Use an email from your Login table</li> <li>Add Locations: Click on the map to add new locations</li> <li>Admin Panel: Admin users can access <code>/admin.html</code> for configuration</li> <li>Walk Sheets: Generate printable canvassing forms with QR codes</li> </ol>"},{"location":"v1/build/map/#maintenance-commands","title":"Maintenance Commands","text":""},{"location":"v1/build/map/#update-application","title":"Update Application","text":"<pre><code>docker-compose down\ngit pull origin main\ndocker-compose build\ndocker-compose up -d\n</code></pre>"},{"location":"v1/build/map/#development-mode","title":"Development Mode","text":"<pre><code>cd app\nnpm install\nnpm run dev\n</code></pre>"},{"location":"v1/build/map/#health-check","title":"Health Check","text":"<pre><code>curl http://localhost:3000/health\n</code></pre>"},{"location":"v1/build/map/#support","title":"Support","text":"<p>For detailed configuration, troubleshooting, and usage instructions, see the Map Configuration Guide.</p>"},{"location":"v1/build/server/","title":"BNKops Server Build","text":"<p>Purpose: a Ubuntu server build-out for general application </p> <p>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. </p> <p>All of the following systems are free and the majority are open source. </p>"},{"location":"v1/build/server/#ubuntu-os","title":"Ubuntu OS","text":"<p>Ubuntu is a Linux distribution derived from Debian and composed mostly of free and open-source software.</p>"},{"location":"v1/build/server/#install-ubuntu","title":"Install Ubuntu","text":""},{"location":"v1/build/server/#post-install","title":"Post Install","text":"<p>Post installation, run update: <pre><code>sudo apt update\n</code></pre></p> <pre><code>sudo apt upgrade\n</code></pre>"},{"location":"v1/build/server/#configuration","title":"Configuration","text":"<p>Further configurations: </p> <ul> <li>User profile was updated to Automatically Login </li> <li>Remote Desktop, Sharing, and Login have all been enabled. </li> <li>Default system settings have been set to dark mode. </li> </ul>"},{"location":"v1/build/server/#vscode-insiders","title":"VSCode Insiders","text":"<p>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.</p>"},{"location":"v1/build/server/#install-using-app-centre","title":"Install Using App Centre","text":""},{"location":"v1/build/server/#obsidian","title":"Obsidian","text":"<p>The free and flexible app for your private\u00a0thoughts.</p>"},{"location":"v1/build/server/#install-using-app-center","title":"Install Using App Center","text":""},{"location":"v1/build/server/#curl","title":"Curl","text":"<p>command line tool and library for transferring data with URLs (since 1998)</p>"},{"location":"v1/build/server/#install","title":"Install","text":"<pre><code>sudo apt install curl \n</code></pre>"},{"location":"v1/build/server/#glances","title":"Glances","text":"<p>Glances an Eye on your system. A top/htop alternative for GNU/Linux, BSD, Mac OS and Windows operating systems.</p>"},{"location":"v1/build/server/#install_1","title":"Install","text":"<pre><code>sudo snap install glances \n</code></pre>"},{"location":"v1/build/server/#syncthing","title":"Syncthing","text":"<p>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.</p>"},{"location":"v1/build/server/#install_2","title":"Install","text":"<pre><code># 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</code></pre> <pre><code># 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</code></pre> <pre><code># Update and install syncthing:\nsudo apt-get update\nsudo apt-get install syncthing\n</code></pre>"},{"location":"v1/build/server/#post-install_1","title":"Post Install","text":"<p>Run syncthing as a system service. <pre><code>sudo systemctl start syncthing@yourusername\n</code></pre></p> <pre><code>sudo systemctl enable syncthing@yourusername\n</code></pre>"},{"location":"v1/build/server/#docker","title":"Docker","text":"<p>Docker helps developers build, share, run, and verify applications anywhere \u2014 without tedious environment configuration or management. <pre><code># 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</code></pre></p> <pre><code>sudo apt-get install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin\n</code></pre>"},{"location":"v1/build/server/#update-users","title":"Update Users","text":"<pre><code>sudo groupadd docker\n</code></pre> <pre><code>sudo usermod -aG docker $USER\n</code></pre> <pre><code>newgrp docker\n</code></pre>"},{"location":"v1/build/server/#enable-on-boot","title":"Enable on Boot","text":"<pre><code>sudo systemctl enable docker.service\nsudo systemctl enable containerd.service\n</code></pre>"},{"location":"v1/build/server/#cloudflared","title":"Cloudflared","text":"<p>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.</p> <pre><code>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</code></pre> <pre><code>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</code></pre> <pre><code>sudo apt-get update && sudo apt-get install cloudflared\n</code></pre>"},{"location":"v1/build/server/#post-install_2","title":"Post Install","text":"<p>Login to Cloudflare <pre><code>cloudflared login\n</code></pre></p>"},{"location":"v1/build/server/#configuration_1","title":"Configuration","text":"<p>The <code>./config.sh</code> and <code>./start-production.sh</code> scripts will properly configure a Cloudflare tunnel and service to put your system online. More info in the Cloudflare Configuration.</p>"},{"location":"v1/build/server/#pandoc","title":"Pandoc","text":"<p>If you need to convert files from one markup format into another, pandoc is your swiss-army knife. </p> <pre><code>sudo apt install pandoc\n</code></pre>"},{"location":"v1/build/site/","title":"Building the Site with MkDocs Material","text":"<p>Welcome! This guide will help you get started building and customizing your site using MkDocs Material.</p>"},{"location":"v1/build/site/#reset-site","title":"Reset Site","text":"<p>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: </p> <pre><code>./reset-site.sh\n</code></pre>"},{"location":"v1/build/site/#how-to-build-your-site-step-by-step","title":"\ud83d\ude80 How to Build Your Site (Step by Step)","text":"<ol> <li>Open your Coder instance. For example: coder.yourdomain.com</li> <li>Go to the mkdocs folder: In the terminal (for a new terminal press Crtl - Shift - ~), type: <pre><code>cd mkdocs\n</code></pre></li> <li>Build the site: Type: <pre><code>mkdocs build\n</code></pre> This creates the static website from your documents and places them in the <code>mkdocs/site</code> directory.</li> </ol> <p>Preview your site locally: Visit localhost:4000 for local development or <code>live.youdomain.com</code> to see a public live load. </p> <ul> <li>All documentation in the <code>mkdocs/docs</code> folder is included automatically.</li> <li>The site uses the beautiful and easy-to-use Material for MkDocs theme.</li> </ul> <p>Material for MkDocs Documentation </p> <p>Build vs Serve</p> <p>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. </p> <p>Running <code>mkdocs build</code> pushes any changes to the <code>site</code> directory, which then a ngnix server pushes them to the production server for public access at your root domain (yourdomain.com). </p> <p>You can think of it as serve/live = draft for personal review and build = save/push to production for the public. </p> <p>This combination allows for rapid development of documentation while ensuring your live site does not get updated until your content is ready. </p>"},{"location":"v1/build/site/#resetting-the-site","title":"\ud83e\uddf9 Resetting the Site","text":"<p>If you want to start fresh:</p> <ol> <li> <p>Delete all folders EXCEPT these folders:</p> <ul> <li><code>/blog</code></li> <li><code>/javascripts</code></li> <li><code>/hooks</code></li> <li><code>/assets</code></li> <li><code>/stylesheets</code></li> <li><code>/overrides</code></li> </ul> </li> <li> <p>Reset the landing page:</p> <ul> <li>Open the main <code>index.md</code> file and remove everything at the very top (the \"front matter\").</li> <li>Or edit <code>/overrides/home.html</code> to change the landing page.</li> </ul> </li> <li> <p>Reset the <code>mkdocs.yml</code> </p> <ul> <li>Open <code>mkdocs.yml</code> and delete the <code>nav</code> section entirely. </li> <li>This action will enable mkdocs to build your site navigation based on file names in the root directory. </li> </ul> </li> </ol>"},{"location":"v1/build/site/#using-ai-to-help-build-your-site","title":"\ud83e\udd16 Using AI to Help Build Your Site","text":"<ul> <li>If you have a claude.ai subscription, you can use powerful AI in your Coder terminal to write or rewrite pages, including a new <code>home.html</code>.</li> <li>All you need to do is open the terminal and type: <pre><code>claude\n</code></pre></li> <li>You can also try local AI tools like Ollama for on-demand help.</li> </ul>"},{"location":"v1/build/site/#first-time-setup-tips","title":"\ud83d\udee0\ufe0f First-Time Setup Tips","text":"<ul> <li>Navigation: Open <code>mkdocs.yml</code> and remove the <code>nav</code> section to start with a blank menu. Add your own pages as you go.</li> <li>Customize the look: Check out the Material for MkDocs customization guide.</li> <li>Live preview: Use <code>mkdocs serve</code> (see above) to see changes instantly as you edit.</li> <li>Custom files: Put your own CSS, JavaScript, or HTML in <code>/assets</code>, <code>/stylesheets</code>, <code>/javascripts</code>, or <code>/overrides</code>.</li> </ul> <p>Quick Start Guide </p>"},{"location":"v1/build/site/#more-resources","title":"\ud83d\udcda More Resources","text":"<ul> <li>MkDocs User Guide </li> <li>Material for MkDocs Features </li> <li>BNKops MKdocs Configuration & Customization</li> </ul> <p>Happy building!</p>"},{"location":"v1/config/","title":"Configuration","text":"<p>There are several configuration steps to building a production ready Changemaker-Lite. </p> <p>In the order we suggest doing them: </p>"},{"location":"v1/config/cloudflare-config/","title":"Configure Cloudflare","text":"<p>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)</p>"},{"location":"v1/config/cloudflare-config/#credentials","title":"Credentials","text":"<p>The <code>config.sh</code> and <code>start-production.sh</code> scripts require the following Cloudflare credentials to function properly:</p>"},{"location":"v1/config/cloudflare-config/#1-cloudflare-api-token","title":"1. Cloudflare API Token","text":"<ul> <li>Purpose: Used to authenticate API requests to Cloudflare for managing DNS records, tunnels, and access policies.</li> <li>Required Permissions:<ul> <li><code>Zone.DNS</code> (Read/Write)</li> <li><code>Account.Cloudflare Tunnel</code> (Read/Write)</li> <li><code>Access</code> (Read/Write)</li> </ul> </li> <li>How to Obtain:<ul> <li>Log in to your Cloudflare account.</li> <li>Go to My Profile > API Tokens > Create Token.</li> <li>Use the Edit zone DNS template and add Cloudflare Tunnel permissions.</li> </ul> </li> </ul>"},{"location":"v1/config/cloudflare-config/#2-cloudflare-zone-id","title":"2. Cloudflare Zone ID","text":"<ul> <li>Purpose: Identifies the specific DNS zone (domain) in Cloudflare where DNS records will be created.</li> <li>How to Obtain:<ul> <li>Log in to your Cloudflare account.</li> <li>Select the domain you want to use.</li> <li>The Zone ID is displayed in the Overview section under API.</li> </ul> </li> </ul>"},{"location":"v1/config/cloudflare-config/#3-cloudflare-account-id","title":"3. Cloudflare Account ID","text":"<ul> <li>Purpose: Identifies your Cloudflare account for tunnel creation and management.</li> <li>How to Obtain:<ul> <li>Log in to your Cloudflare account.</li> <li>Go to My Profile > API Tokens.</li> <li>The Account ID is displayed at the top of the page.</li> </ul> </li> </ul>"},{"location":"v1/config/cloudflare-config/#4-cloudflare-tunnel-id-optional-in-configsh-required-in-start-productionsh","title":"4. Cloudflare Tunnel ID (Optional in config.sh, Required in start-production.sh)","text":"<p>Automatic Configuration of Tunnel</p> <p>The <code>start-production.sh</code> script will automatically create a tunnel and system service for Cloudflare.</p> <ul> <li>Purpose: Identifies the specific Cloudflare Tunnel that will be used to route traffic to your services.</li> <li>How to Obtain:<ul> <li>This is automatically generated when you create a tunnel using <code>cloudflared tunnel create</code> or via the Cloudflare dashboard.</li> </ul> </li> <li>The start-production.sh script will create this for you if it doesn't exist.</li> </ul>"},{"location":"v1/config/cloudflare-config/#summary-of-required-credentials","title":"Summary of Required Credentials:","text":"<pre><code># 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</code></pre>"},{"location":"v1/config/cloudflare-config/#notes","title":"Notes:","text":"<ul> <li>The config.sh script will prompt you for these credentials during setup.</li> <li>The start-production.sh script will verify these credentials and use them to configure DNS records, create tunnels, and set up access policies.</li> <li>Ensure that the API token has the correct permissions, or the scripts will fail to configure Cloudflare services.</li> </ul>"},{"location":"v1/config/coder/","title":"Coder Server Configuration","text":"<p>This section describes the configuration and features of the code-server environment.</p>"},{"location":"v1/config/coder/#accessing-code-server","title":"Accessing Code Server","text":"<ul> <li>URL: <code>http://localhost:8080</code></li> <li>Authentication: Password-based (see below for password retrieval)</li> </ul>"},{"location":"v1/config/coder/#retrieving-the-code-server-password","title":"Retrieving the Code Server Password","text":"<p>After the first build, the code-server password is stored in:</p> <pre><code>configs/code-server/.config/code-server/config.yaml\n</code></pre> <p>Look for the <code>password:</code> field in that file. For example:</p> <pre><code>password: 0c0dca951a2d12eff1665817\n</code></pre> <p>Note: It is recommended not to change this password manually, as it is securely generated.</p>"},{"location":"v1/config/coder/#main-configuration-options","title":"Main Configuration Options","text":"<ul> <li><code>bind-addr</code>: The address and port code-server listens on (default: <code>127.0.0.1:8080</code>)</li> <li><code>auth</code>: Authentication method (default: <code>password</code>)</li> <li><code>password</code>: The login password (see above)</li> <li><code>cert</code>: Whether to use HTTPS (default: <code>false</code>)</li> </ul>"},{"location":"v1/config/coder/#installed-tools-and-features","title":"Installed Tools and Features","text":"<p>The code-server environment includes:</p> <ul> <li>Node.js 18+ and npm</li> <li>Claude Code (<code>@anthropic-ai/claude-code</code>) globally installed</li> <li>Python 3 and tools:</li> <li><code>python3-pip</code>, <code>python3-venv</code>, <code>python3-full</code>, <code>pipx</code></li> <li>Image and PDF processing libraries:</li> <li><code>CairoSVG</code>, <code>Pillow</code>, <code>libcairo2-dev</code>, <code>libfreetype6-dev</code>, <code>libjpeg-dev</code>, <code>libpng-dev</code>, <code>libwebp-dev</code>, <code>libtiff5-dev</code>, <code>libopenjp2-7-dev</code>, <code>liblcms2-dev</code></li> <li><code>weasyprint</code>, <code>fonts-roboto</code></li> <li>Git for version control and plugin management</li> <li>Build tools: <code>build-essential</code>, <code>pkg-config</code>, <code>python3-dev</code>, <code>zlib1g-dev</code></li> <li>MkDocs Material and a wide range of MkDocs plugins, installed in a dedicated Python virtual environment at <code>/home/coder/.venv/mkdocs</code></li> <li>Convenience script: <code>run-mkdocs</code> for running MkDocs commands easily</li> </ul>"},{"location":"v1/config/coder/#using-mkdocs","title":"Using MkDocs","text":"<p>The virtual environment for MkDocs is automatically added to your <code>PATH</code>. You can run MkDocs commands directly, or use the provided script. For example, to build the site, from a clean terminal we would rung:</p> <pre><code>cd mkdocs \nmkdocs build\n</code></pre>"},{"location":"v1/config/coder/#claude-code-integration","title":"Claude Code Integration","text":"<p>The code-server environment comes with Claude Code (<code>@anthropic-ai/claude-code</code>) globally installed via npm.</p>"},{"location":"v1/config/coder/#what-is-claude-code","title":"What is Claude Code?","text":"<p>Claude Code is an AI-powered coding assistant by Anthropic, designed to help you write, refactor, and understand code directly within your development environment.</p>"},{"location":"v1/config/coder/#usage","title":"Usage","text":"<ul> <li>Access Claude Code features through the command palette or sidebar in code-server.</li> <li>Use Claude Code to generate code, explain code snippets, or assist with documentation and refactoring tasks.</li> <li>For more information, refer to the Claude Code documentation.</li> </ul> <p>Note: Claude Code requires an API key or account with Anthropic for full functionality. Refer to the extension settings for configuration.</p>"},{"location":"v1/config/coder/#call-claude","title":"Call Claude","text":"<p>To use claude simply type claude into the terminal and follow instructions. </p> <pre><code>claude\n</code></pre>"},{"location":"v1/config/coder/#shell-environment","title":"Shell Environment","text":"<p>The <code>.bashrc</code> is configured to include the MkDocs virtual environment and user-local binaries in your <code>PATH</code> for convenience.</p>"},{"location":"v1/config/coder/#code-navigation-and-editing-features","title":"Code Navigation and Editing Features","text":"<p>The code-server environment provides robust code navigation and editing features, including:</p> <ul> <li>IntelliSense: Smart code completions based on variable types, function definitions, and imported modules.</li> <li>Code Navigation: Easily navigate to definitions, references, and symbol searches within your codebase.</li> <li>Debugging Support: Integrated debugging support for Node.js and Python, with breakpoints, call stacks, and interactive consoles.</li> <li>Terminal Access: Built-in terminal access to run commands, scripts, and version control operations.</li> </ul>"},{"location":"v1/config/coder/#collaboration-features","title":"Collaboration Features","text":"<p>Code-server includes features to support collaboration:</p> <ul> <li>Live Share: Collaborate in real-time with others, sharing your code and terminal sessions.</li> <li>ChatGPT Integration: AI-powered code assistance and chat-based collaboration.</li> </ul>"},{"location":"v1/config/coder/#security-considerations","title":"Security Considerations","text":"<p>When using code-server, consider the following security aspects:</p> <ul> <li>Password Management: The default password is securely generated. Do not share it or expose it in public repositories.</li> <li>Network Security: Ensure that your firewall settings allow access to the code-server port (default: 8080) only from trusted networks.</li> <li>Data Privacy: Be cautious when uploading sensitive data or code to the server. Use environment variables or secure vaults for sensitive information.</li> </ul>"},{"location":"v1/config/coder/#ollama-integration","title":"Ollama Integration","text":"<p>The code-server environment includes Ollama, a tool for running large language models locally on your machine.</p>"},{"location":"v1/config/coder/#what-is-ollama","title":"What is Ollama?","text":"<p>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.</p>"},{"location":"v1/config/coder/#getting-started-with-ollama","title":"Getting Started with Ollama","text":""},{"location":"v1/config/coder/#staring-ollama","title":"Staring Ollama","text":"<p>For ollama to be available, you need to open a terminal and run: </p> <pre><code>ollama serve\n</code></pre> <p>This will start the ollama server and you can then proceed to pulling a model and chatting. </p>"},{"location":"v1/config/coder/#pulling-a-model","title":"Pulling a Model","text":"<p>To get started, you'll need to pull a model. For development and testing, we recommend starting with a smaller model like Gemma 2B:</p> <pre><code>ollama pull gemma2:2b\n</code></pre> <p>For even lighter resource usage, you can use the 1B parameter version:</p> <pre><code>ollama pull gemma2:1b\n</code></pre>"},{"location":"v1/config/coder/#running-a-model","title":"Running a Model","text":"<p>Once you've pulled a model, you can start an interactive session:</p> <pre><code>ollama run gemma2:2b\n</code></pre>"},{"location":"v1/config/coder/#available-models","title":"Available Models","text":"<p>Popular models available through Ollama include:</p> <ul> <li>Gemma 2 (1B, 2B, 9B, 27B): Google's efficient language models</li> <li>Llama 3.2 (1B, 3B, 11B, 90B): Meta's latest language models</li> <li>Qwen 2.5 (0.5B, 1.5B, 3B, 7B, 14B, 32B, 72B): Alibaba's multilingual models</li> <li>Phi 3.5 (3.8B): Microsoft's compact language model</li> <li>Code Llama (7B, 13B, 34B): Specialized for code generation</li> </ul>"},{"location":"v1/config/coder/#using-ollama-in-your-development-workflow","title":"Using Ollama in Your Development Workflow","text":""},{"location":"v1/config/coder/#api-access","title":"API Access","text":"<p>Ollama provides a REST API that runs on <code>http://localhost:11434</code> by default. You can integrate this into your applications:</p> <pre><code>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</code></pre>"},{"location":"v1/config/coder/#model-management","title":"Model Management","text":"<p>List installed models: <pre><code>ollama list\n</code></pre></p> <p>Remove a model: <pre><code>ollama rm gemma2:2b\n</code></pre></p> <p>Show model information: <pre><code>ollama show gemma2:2b\n</code></pre></p>"},{"location":"v1/config/coder/#resource-considerations","title":"Resource Considerations","text":"<ul> <li>1B models: Require ~1GB RAM, suitable for basic tasks and resource-constrained environments</li> <li>2B models: Require ~2GB RAM, good balance of capability and resource usage</li> <li>Larger models: Provide better performance but require significantly more resources</li> </ul>"},{"location":"v1/config/coder/#integration-with-development-tools","title":"Integration with Development Tools","text":"<p>Ollama can be integrated with various development tools and editors through its API, enabling features like:</p> <ul> <li>Code completion and generation</li> <li>Documentation writing assistance</li> <li>Code review and explanation</li> <li>Automated testing suggestions</li> </ul> <p>For more information, visit the Ollama documentation.</p> <p>For more detailed information on configuring and using code-server, refer to the official code-server documentation.</p>"},{"location":"v1/config/map/","title":"Map Configuration","text":"<p>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.</p>"},{"location":"v1/config/map/#features","title":"Features","text":"<ul> <li>\ud83d\uddfa\ufe0f Interactive map visualization with OpenStreetMap</li> <li>\ud83d\udccd Real-time geolocation support for adding locations</li> <li>\u2795 Add new locations directly from the map interface</li> <li>\ud83d\udd04 Auto-refresh every 30 seconds</li> <li>\ud83d\udcf1 Responsive design for mobile devices</li> <li>\ud83d\udd12 Secure API proxy to protect NocoDB credentials</li> <li>\ud83d\udc64 User authentication with login system</li> <li>\u2699\ufe0f Admin panel for system configuration</li> <li>\ud83c\udfaf Configurable map start location</li> <li>\ud83d\udcc4 Walk Sheet generator for door-to-door canvassing</li> <li>\ud83d\udd17 QR code integration for digital resources</li> <li>\ud83d\udc33 Docker containerization for easy deployment</li> <li>\ud83c\udd93 100% open source (no proprietary dependencies)</li> </ul>"},{"location":"v1/config/map/#setup-process-overview","title":"Setup Process Overview","text":"<p>The setup process involves several steps that must be completed in order:</p> <ol> <li>Get NocoDB API Token - Create an API token in your NocoDB instance</li> <li>Configure Environment - Update the <code>.env</code> file with your NocoDB details</li> <li>Auto-Create Database Structure - Run the build script to create required tables</li> <li>Get Table URLs - Find and copy the URLs for the newly created tables</li> <li>Update Environment with URLs - Add the table URLs to your <code>.env</code> file</li> <li>Build and Deploy - Build the Docker image and start the application</li> </ol>"},{"location":"v1/config/map/#prerequisites","title":"Prerequisites","text":"<ul> <li>Docker and Docker Compose installed</li> <li>NocoDB instance with API access</li> <li>Domain name (optional but recommended for production)</li> </ul>"},{"location":"v1/config/map/#step-1-get-nocodb-api-token","title":"Step 1: Get NocoDB API Token","text":"<ol> <li>Login to your NocoDB instance</li> <li>Click your user icon \u2192 Account Settings</li> <li>Go to the API Tokens tab</li> <li>Click Create new token</li> <li>Set the following permissions:</li> <li>Read: Yes</li> <li>Write: Yes</li> <li>Delete: Yes (optional, for admin functions)</li> <li>Copy the generated token - you'll need it for the next step</li> </ol> <p>Token Security</p> <p>Keep your API token secure and never commit it to version control. The token provides full access to your NocoDB data.</p>"},{"location":"v1/config/map/#step-2-configure-environment","title":"Step 2: Configure Environment","text":"<p>Edit the <code>.env</code> file in the <code>map/</code> directory:</p> <pre><code># 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</code></pre>"},{"location":"v1/config/map/#required-configuration","title":"Required Configuration","text":"<ul> <li><code>NOCODB_API_URL</code>: Your NocoDB instance API URL (usually ends with <code>/api/v1</code>)</li> <li><code>NOCODB_API_TOKEN</code>: The token you created in Step 1</li> <li><code>SESSION_SECRET</code>: Generate a secure random string for session encryption</li> </ul>"},{"location":"v1/config/map/#optional-configuration","title":"Optional Configuration","text":"<ul> <li><code>DEFAULT_LAT/LNG/ZOOM</code>: Default map center and zoom level</li> <li><code>BOUND_*</code>: Map boundaries to restrict where users can add points</li> <li><code>COOKIE_DOMAIN</code>: Your domain for cookie security</li> <li><code>ALLOWED_ORIGINS</code>: Comma-separated list of allowed origins for CORS</li> </ul>"},{"location":"v1/config/map/#step-3-auto-create-database-structure","title":"Step 3: Auto-Create Database Structure","text":"<p>The <code>build-nocodb.sh</code> script will automatically create the required tables in your NocoDB instance.</p> <pre><code>cd map\nchmod +x build-nocodb.sh\n./build-nocodb.sh\n</code></pre>"},{"location":"v1/config/map/#what-the-script-creates","title":"What the Script Creates","text":"<p>The script creates three tables with the following structure:</p>"},{"location":"v1/config/map/#1-locations-table","title":"1. Locations Table","text":"<p>Main table for storing map data:</p> <ul> <li><code>Geo-Location</code> (Geo-Data): Format \"latitude;longitude\"</li> <li><code>latitude</code> (Decimal): Precision 10, Scale 8</li> <li><code>longitude</code> (Decimal): Precision 11, Scale 8</li> <li><code>First Name</code> (Single Line Text): Person's first name</li> <li><code>Last Name</code> (Single Line Text): Person's last name</li> <li><code>Email</code> (Email): Email address</li> <li><code>Phone</code> (Single Line Text): Phone number</li> <li><code>Unit Number</code> (Single Line Text): Unit or apartment number</li> <li><code>Address</code> (Single Line Text): Street address</li> <li><code>Support Level</code> (Single Select): Options: \"1\", \"2\", \"3\", \"4\"</li> <li>1 = Strong Support (Green)</li> <li>2 = Moderate Support (Yellow)</li> <li>3 = Low Support (Orange)</li> <li>4 = No Support (Red)</li> <li><code>Sign</code> (Checkbox): Has campaign sign</li> <li><code>Sign Size</code> (Single Select): Options: \"Regular\", \"Large\", \"Unsure\"</li> <li><code>Notes</code> (Long Text): Additional details and comments</li> </ul>"},{"location":"v1/config/map/#2-login-table","title":"2. Login Table","text":"<p>User authentication table:</p> <ul> <li><code>Email</code> (Email): User email address (Primary)</li> <li><code>Name</code> (Single Line Text): User display name</li> <li><code>Admin</code> (Checkbox): Admin privileges</li> </ul>"},{"location":"v1/config/map/#3-settings-table","title":"3. Settings Table","text":"<p>Admin configuration table:</p> <ul> <li><code>key</code> (Single Line Text): Setting identifier</li> <li><code>title</code> (Single Line Text): Display name</li> <li><code>value</code> (Long Text): Setting value</li> <li><code>Geo-Location</code> (Text): Format \"latitude;longitude\"</li> <li><code>latitude</code> (Decimal): Precision 10, Scale 8</li> <li><code>longitude</code> (Decimal): Precision 11, Scale 8</li> <li><code>zoom</code> (Number): Map zoom level</li> <li><code>category</code> (Single Select): Setting category</li> <li><code>updated_by</code> (Single Line Text): Last updater email</li> <li><code>updated_at</code> (DateTime): Last update time</li> <li><code>qr_code_1_image</code> (Attachment): QR code 1 image</li> <li><code>qr_code_2_image</code> (Attachment): QR code 2 image</li> <li><code>qr_code_3_image</code> (Attachment): QR code 3 image</li> </ul>"},{"location":"v1/config/map/#default-data","title":"Default Data","text":"<p>The script also creates: - A default admin user (admin@example.com) - A default start location setting</p>"},{"location":"v1/config/map/#step-4-get-table-urls","title":"Step 4: Get Table URLs","text":"<p>After the script completes successfully:</p> <ol> <li>Login to your NocoDB instance</li> <li>Navigate to your project (should be named \"Map Viewer Project\")</li> <li>For each table, get the view URL:</li> <li>Click on the table name</li> <li>Copy the URL from your browser's address bar</li> <li>The URL should look like: <code>https://your-nocodb.com/dashboard/#/nc/project-id/table-id</code></li> </ol> <p>You need URLs for: - Locations table \u2192 <code>NOCODB_VIEW_URL</code> - Login table \u2192 <code>NOCODB_LOGIN_SHEET</code> - Settings table \u2192 <code>NOCODB_SETTINGS_SHEET</code></p>"},{"location":"v1/config/map/#step-5-update-environment-with-urls","title":"Step 5: Update Environment with URLs","text":"<p>Edit your <code>.env</code> file and add the table URLs:</p> <pre><code># 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</code></pre> <p>URL Format</p> <p>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.</p>"},{"location":"v1/config/map/#step-6-build-and-deploy","title":"Step 6: Build and Deploy","text":"<p>Build the Docker image and start the application:</p> <pre><code># Build the Docker image\ndocker-compose build\n\n# Start the application\ndocker-compose up -d\n</code></pre>"},{"location":"v1/config/map/#verify-deployment","title":"Verify Deployment","text":"<ol> <li> <p>Check that the container is running: <pre><code>docker-compose ps\n</code></pre></p> </li> <li> <p>Check the logs: <pre><code>docker-compose logs -f map-viewer\n</code></pre></p> </li> <li> <p>Access the application at <code>http://localhost:3000</code> (or your configured domain)</p> </li> </ol>"},{"location":"v1/config/map/#using-the-map-system","title":"Using the Map System","text":""},{"location":"v1/config/map/#user-interface","title":"User Interface","text":""},{"location":"v1/config/map/#main-map-view","title":"Main Map View","text":"<ul> <li>Interactive Map: Click and drag to navigate</li> <li>Add Location: Click on the map to add a new location</li> <li>Search: Use the search bar to find addresses</li> <li>Refresh: Data refreshes automatically every 30 seconds</li> </ul>"},{"location":"v1/config/map/#location-markers","title":"Location Markers","text":"<ul> <li>Green: Strong Support (Level 1)</li> <li>Yellow: Moderate Support (Level 2)</li> <li>Orange: Low Support (Level 3)</li> <li>Red: No Support (Level 4)</li> </ul>"},{"location":"v1/config/map/#adding-locations","title":"Adding Locations","text":"<ol> <li>Click on the map where you want to add a location</li> <li>Fill out the form with contact information</li> <li>Select support level and sign information</li> <li>Add any relevant notes</li> <li>Click \"Save Location\"</li> </ol>"},{"location":"v1/config/map/#authentication","title":"Authentication","text":""},{"location":"v1/config/map/#user-login","title":"User Login","text":"<ul> <li>Users must be added to the Login table in NocoDB</li> <li>Login with email address (no password required for simplified setup)</li> <li>Admin users have additional privileges</li> </ul>"},{"location":"v1/config/map/#admin-access","title":"Admin Access","text":"<ul> <li>Admin users can access <code>/admin.html</code></li> <li>Configure map start location</li> <li>Set up walk sheet generator</li> <li>Manage QR codes and settings</li> </ul>"},{"location":"v1/config/map/#admin-panel-features","title":"Admin Panel Features","text":""},{"location":"v1/config/map/#start-location-configuration","title":"Start Location Configuration","text":"<ul> <li>Interactive Map: Visual interface for selecting coordinates</li> <li>Real-time Preview: See changes immediately</li> <li>Validation: Built-in coordinate and zoom level validation</li> </ul>"},{"location":"v1/config/map/#walk-sheet-generator","title":"Walk Sheet Generator","text":"<ul> <li>Printable Forms: Generate 8.5x11 walk sheets for door-to-door canvassing</li> <li>QR Code Integration: Add up to 3 QR codes with custom URLs and labels</li> <li>Form Field Matching: Automatically matches fields from the main location form</li> <li>Live Preview: See changes as you type</li> <li>Print Optimization: Proper formatting for printing or PDF export</li> </ul>"},{"location":"v1/config/map/#api-endpoints","title":"API Endpoints","text":""},{"location":"v1/config/map/#public-endpoints","title":"Public Endpoints","text":"<ul> <li><code>GET /api/locations</code> - Fetch all locations (requires auth)</li> <li><code>POST /api/locations</code> - Create new location (requires auth)</li> <li><code>GET /api/locations/:id</code> - Get single location (requires auth)</li> <li><code>PUT /api/locations/:id</code> - Update location (requires auth)</li> <li><code>DELETE /api/locations/:id</code> - Delete location (requires auth)</li> <li><code>GET /api/config/start-location</code> - Get map start location</li> <li><code>GET /health</code> - Health check</li> </ul>"},{"location":"v1/config/map/#authentication-endpoints","title":"Authentication Endpoints","text":"<ul> <li><code>POST /api/auth/login</code> - User login</li> <li><code>GET /api/auth/check</code> - Check authentication status</li> <li><code>POST /api/auth/logout</code> - User logout</li> </ul>"},{"location":"v1/config/map/#admin-endpoints-requires-admin-privileges","title":"Admin Endpoints (requires admin privileges)","text":"<ul> <li><code>GET /api/admin/start-location</code> - Get start location with source info</li> <li><code>POST /api/admin/start-location</code> - Update map start location</li> <li><code>GET /api/admin/walk-sheet-config</code> - Get walk sheet configuration</li> <li><code>POST /api/admin/walk-sheet-config</code> - Save walk sheet configuration</li> </ul>"},{"location":"v1/config/map/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v1/config/map/#common-issues","title":"Common Issues","text":""},{"location":"v1/config/map/#locations-not-showing","title":"Locations not showing","text":"<ul> <li>Verify table has required columns (<code>Geo-Location</code>, <code>latitude</code>, <code>longitude</code>)</li> <li>Check that coordinates are valid numbers</li> <li>Ensure API token has read permissions</li> <li>Verify <code>NOCODB_VIEW_URL</code> is correct</li> </ul>"},{"location":"v1/config/map/#cannot-add-locations","title":"Cannot add locations","text":"<ul> <li>Verify API token has write permissions</li> <li>Check browser console for errors</li> <li>Ensure coordinates are within valid ranges</li> <li>Verify user is authenticated</li> </ul>"},{"location":"v1/config/map/#authentication-issues","title":"Authentication issues","text":"<ul> <li>Verify login table is properly configured</li> <li>Check that user email exists in Login table</li> <li>Ensure <code>NOCODB_LOGIN_SHEET</code> URL is correct</li> </ul>"},{"location":"v1/config/map/#build-script-failures","title":"Build script failures","text":"<ul> <li>Check that <code>NOCODB_API_URL</code> and <code>NOCODB_API_TOKEN</code> are correct</li> <li>Verify NocoDB instance is accessible</li> <li>Check network connectivity</li> <li>Review script output for specific error messages</li> </ul>"},{"location":"v1/config/map/#development-mode","title":"Development Mode","text":"<p>For development and debugging:</p> <pre><code>cd map/app\nnpm install\nnpm run dev\n</code></pre> <p>This will start the application with hot reload and detailed logging.</p>"},{"location":"v1/config/map/#logs-and-monitoring","title":"Logs and Monitoring","text":"<p>View application logs: <pre><code>docker-compose logs -f map-viewer\n</code></pre></p> <p>Check health status: <pre><code>curl http://localhost:3000/health\n</code></pre></p>"},{"location":"v1/config/map/#security-considerations","title":"Security Considerations","text":"<ol> <li>API Token Security: Keep tokens secure and rotate regularly</li> <li>HTTPS: Use HTTPS in production</li> <li>CORS Configuration: Set appropriate <code>ALLOWED_ORIGINS</code></li> <li>Cookie Security: Configure <code>COOKIE_DOMAIN</code> properly</li> <li>Input Validation: All inputs are validated server-side</li> <li>Rate Limiting: API endpoints have rate limiting</li> <li>Session Security: Use a strong <code>SESSION_SECRET</code></li> </ol>"},{"location":"v1/config/map/#maintenance","title":"Maintenance","text":""},{"location":"v1/config/map/#regular-updates","title":"Regular Updates","text":"<pre><code># 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</code></pre>"},{"location":"v1/config/map/#backup-considerations","title":"Backup Considerations","text":"<ul> <li>NocoDB data is stored in your NocoDB instance</li> <li>Back up your <code>.env</code> file securely</li> <li>Consider backing up QR code images from the Settings table</li> </ul>"},{"location":"v1/config/map/#performance-tips","title":"Performance Tips","text":"<ul> <li>Monitor NocoDB performance and scaling</li> <li>Consider enabling caching for high-traffic deployments</li> <li>Use CDN for static assets if needed</li> <li>Monitor Docker container resource usage</li> </ul>"},{"location":"v1/config/map/#support","title":"Support","text":"<p>For issues or questions: 1. Check the troubleshooting section above 2. Review NocoDB documentation 3. Check Docker and Docker Compose documentation 4. Open an issue on GitHub</p>"},{"location":"v1/config/mkdocs/","title":"MkDocs Customization & Features Overview","text":"<p>BNKops has been building our own features, widgets, and css styles for MKdocs material theme. </p> <p>This document explains the custom styling, repository widgets, and key features enabled in this MkDocs site.</p> <p>For more info on how to build your site see Site Build</p>"},{"location":"v1/config/mkdocs/#using-the-repository-widget-in-documentation","title":"Using the Repository Widget in Documentation","text":"<p>You can embed repository widgets directly in your Markdown documentation to display live repository stats and metadata. To do this, add a <code>div</code> with the appropriate class and <code>data-repo</code> attribute for the repository you want to display.</p> <p>Example (for a Gitea repository): <pre><code><div class=\"gitea-widget\" data-repo=\"admin/changemaker.lite\"></div>\n</code></pre></p> <p>This will render a styled card with information about the <code>admin/changemaker.lite</code> repository:</p> <p>Options: You can control the widget display with additional data attributes: - <code>data-show-description=\"false\"</code> \u2014 Hide the description - <code>data-show-language=\"false\"</code> \u2014 Hide the language - <code>data-show-last-update=\"false\"</code> \u2014 Hide the last update date</p> <p>Example with options: <pre><code><div class=\"gitea-widget\" data-repo=\"admin/changemaker.lite\" data-show-description=\"false\"></div>\n</code></pre></p> <p>For GitHub repositories, use the <code>github-widget</code> class: <pre><code><div class=\"github-widget\" data-repo=\"lyqht/mini-qr\"></div>\n</code></pre></p>"},{"location":"v1/config/mkdocs/#custom-css-styling-stylesheetsextracss","title":"Custom CSS Styling (<code>stylesheets/extra.css</code>)","text":"<p>The <code>extra.css</code> file provides extensive custom styling for the site, including:</p> <ul> <li> <p>Login and Git Code Buttons: Custom styles for <code>.login-button</code> and <code>.git-code-button</code> to create visually distinct, modern buttons with hover effects.</p> </li> <li> <p>Code Block Improvements: Forces code blocks to wrap text (<code>white-space: pre-wrap</code>) and ensures inline code and tables with code display correctly on all devices.</p> </li> <li> <p>GitHub Widget Styles: Styles for <code>.github-widget</code> and its subcomponents, including:</p> </li> <li>Card-like container with gradient backgrounds and subtle box-shadows.</li> <li>Header with icon, repo link, and stats (stars, forks, issues).</li> <li>Description area with accent border.</li> <li>Footer with language, last update, and license info.</li> <li>Loading and error states with spinners and error messages.</li> <li>Responsive grid layout for multiple widgets.</li> <li>Compact variant for smaller displays.</li> <li> <p>Dark mode adjustments.</p> </li> <li> <p>Gitea Widget Styles: Similar to GitHub widget, but with Gitea branding (green accents). Includes <code>.gitea-widget</code>, <code>.gitea-widget-container</code>, and related classes for header, stats, description, footer, loading, and error states.</p> </li> <li> <p>Responsive Design: Media queries ensure widgets and tables look good on mobile devices.</p> </li> </ul>"},{"location":"v1/config/mkdocs/#repository-widgets","title":"Repository Widgets","text":""},{"location":"v1/config/mkdocs/#data-generation-hooksrepo_widget_hookpy","title":"Data Generation (<code>hooks/repo_widget_hook.py</code>)","text":"<ul> <li>Purpose: During the MkDocs build, this hook fetches metadata for a list of GitHub and Gitea repositories and writes JSON files to <code>docs/assets/repo-data/</code>.</li> <li>How it works:</li> <li>Runs before build (unless in <code>serve</code> mode).</li> <li>Fetches repo data (stars, forks, issues, language, etc.) via GitHub/Gitea APIs.</li> <li>Outputs a JSON file per repo (e.g., <code>lyqht-mini-qr.json</code>).</li> <li>Used by frontend widgets for fast, client-side rendering.</li> </ul>"},{"location":"v1/config/mkdocs/#github-widget-javascriptsgithub-widgetjs","title":"GitHub Widget (<code>javascripts/github-widget.js</code>)","text":"<ul> <li>Purpose: Renders a card for each GitHub repository using the pre-generated JSON data.</li> <li>Features:</li> <li>Displays repo name, link, stars, forks, open issues, language, last update, and license.</li> <li>Shows loading spinner while fetching data.</li> <li>Handles errors gracefully.</li> <li>Supports dynamic content (re-initializes on DOM changes).</li> <li>Language color coding for popular languages.</li> </ul>"},{"location":"v1/config/mkdocs/#gitea-widget-javascriptsgitea-widgetjs","title":"Gitea Widget (<code>javascripts/gitea-widget.js</code>)","text":"<ul> <li>Purpose: Renders a card for each Gitea repository using the pre-generated JSON data.</li> <li>Features:</li> <li>Similar to GitHub widget, but styled for Gitea.</li> <li>Shows repo name, link, stars, forks, open issues, language, last update.</li> <li>Loading and error states.</li> <li>Language color coding.</li> </ul>"},{"location":"v1/config/mkdocs/#mkdocs-features-mkdocsyml","title":"MkDocs Features (<code>mkdocs.yml</code>)","text":"<p>Key features and plugins enabled:</p> <ul> <li> <p>Material Theme: Modern, responsive UI with dark/light mode toggle, custom fonts, and accent colors.</p> </li> <li> <p>Navigation Enhancements: </p> </li> <li>Tabs, sticky navigation, instant loading, breadcrumbs, and sectioned navigation.</li> <li> <p>Table of contents with permalinks.</p> </li> <li> <p>Content Features: </p> </li> <li>Code annotation, copy buttons, tooltips, and improved code highlighting.</li> <li> <p>Admonitions, tabbed content, task lists, and emoji support.</p> </li> <li> <p>Plugins:</p> </li> <li>Search: Advanced search with custom tokenization.</li> <li>Social: OpenGraph/social card generation.</li> <li>Blog: Blogging support with archives and categories.</li> <li> <p>Tags: Tagging for content organization.</p> </li> <li> <p>Custom Hooks: </p> </li> <li> <p><code>repo_widget_hook.py</code> for repository widget data.</p> </li> <li> <p>Extra CSS/JS: </p> </li> <li> <p>Custom styles and scripts for widgets and homepage.</p> </li> <li> <p>Extra Configuration: </p> </li> <li>Social links, copyright.</li> </ul>"},{"location":"v1/config/mkdocs/#summary","title":"Summary","text":"<p>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.</p>"},{"location":"v1/manual/","title":"Manuals","text":"<p>The following are manuals, some accompanied by videos, on the use of the system. </p>"},{"location":"v1/manual/map/","title":"Map System Manual","text":"<p>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)</p>"},{"location":"v1/manual/map/#1-getting-started","title":"1. Getting Started","text":""},{"location":"v1/manual/map/#logging-in","title":"Logging In","text":"<ol> <li>Go to your map site URL (e.g., <code>https://yoursite.com</code> or <code>http://localhost:3000</code>).</li> <li>Enter your email and password on the login page.</li> <li>Click Login.</li> <li>If you forget your password, use the Reset Password link or contact an admin.</li> <li>Password Recovery: Check your email for reset instructions if SMTP is configured. (Insert screenshot - login page)</li> </ol>"},{"location":"v1/manual/map/#user-types-permissions","title":"User Types & Permissions","text":"<ul> <li>Admin: Full access to all features, user management, and system configuration</li> <li>User: Access to map, shifts, profile management, and location data</li> <li>Temp: Limited access (add/edit locations only, expires automatically after shift date)</li> </ul>"},{"location":"v1/manual/map/#2-interactive-map-features","title":"2. Interactive Map Features","text":""},{"location":"v1/manual/map/#basic-map-navigation","title":"Basic Map Navigation","text":"<ol> <li>After login, you'll see the interactive map with location markers.</li> <li>Use mouse or touch to pan and zoom around the map.</li> <li>Your current location may appear as a blue dot (if location services enabled).</li> <li>Use the zoom controls (\u00b1) or mouse wheel to adjust map scale. (Insert screenshot - main map view)</li> </ol>"},{"location":"v1/manual/map/#advanced-search-ctrlk","title":"Advanced Search (Ctrl+K)","text":"<ol> <li>Press Ctrl+K anywhere on the site to open the universal search.</li> <li>Search for:</li> <li>Addresses: Find and navigate to specific locations</li> <li>Documentation: Search help articles and guides</li> <li>Locations: Find existing data points by name or details</li> <li>Click results to navigate directly to locations on the map.</li> <li>QR Code Generation: Search results include QR codes for easy mobile sharing. (Insert screenshot - search interface)</li> </ol>"},{"location":"v1/manual/map/#map-overlays-cuts","title":"Map Overlays (Cuts)","text":"<ol> <li>Public Cuts: Geographic overlays (wards, neighborhoods, districts) are automatically displayed.</li> <li>Cut Selector: Use the multi-select dropdown to show/hide different cuts.</li> <li>Mobile Interface: On mobile, tap the \ud83d\uddfa\ufe0f button to manage overlays.</li> <li>Legend: View active cuts with color coding and labels.</li> <li>Cuts help organize and filter location data by geographic regions. (Insert screenshot - cuts interface)</li> </ol>"},{"location":"v1/manual/map/#3-location-management","title":"3. Location Management","text":""},{"location":"v1/manual/map/#adding-new-locations","title":"Adding New Locations","text":"<ol> <li>Click the Add Location button (+ icon) on the map.</li> <li>Click on the map where you want to place the new location.</li> <li>Fill out the comprehensive form:</li> <li>Personal: First Name, Last Name, Email, Phone, Unit Number</li> <li>Political: Support Level (1-4 scale), Party Affiliation</li> <li>Address: Street Address (auto-geocoded when possible)</li> <li>Campaign: Lawn Sign (Yes/No/Maybe), Sign Size, Volunteer Interest</li> <li>Notes: Additional information and comments</li> <li>Address Confirmation: System validates and confirms addresses when possible.</li> <li>Click Save to add the location marker. (Insert screenshot - add location form)</li> </ol>"},{"location":"v1/manual/map/#editing-and-managing-locations","title":"Editing and Managing Locations","text":"<ol> <li>Click on any location marker to view details.</li> <li>Popup Actions:</li> <li>Edit: Modify all location details</li> <li>Move: Drag marker to new position (admin/user only)</li> <li>Delete: Remove location (admin/user only - hidden for temp users)</li> <li>Quick Actions: Email, phone, or text contact directly from popup.</li> <li>Support Level Color Coding: Markers change color based on support level.</li> <li>Apartment View: Special clustering for apartment buildings. (Insert screenshot - location popup)</li> </ol>"},{"location":"v1/manual/map/#bulk-data-import","title":"Bulk Data Import","text":"<ol> <li>Admin Panel \u2192 Data Converter \u2192 Upload CSV</li> <li>Supported Formats: CSV files with address data</li> <li>Batch Geocoding: Automatically converts addresses to coordinates</li> <li>Progress Tracking: Visual progress bar with success/failure reporting</li> <li>Error Handling: Downloadable error reports for failed geocoding</li> <li>Validation: Preview and verify data before final import</li> <li>Edmonton Data: Pre-configured for City of Edmonton neighborhood data. (Insert screenshot - data import interface)</li> </ol>"},{"location":"v1/manual/map/#4-volunteer-shift-management","title":"4. Volunteer Shift Management","text":""},{"location":"v1/manual/map/#public-shift-signup-no-login-required","title":"Public Shift Signup (No Login Required)","text":"<ol> <li>Visit the Public Shifts page (accessible without account).</li> <li>Browse available volunteer opportunities with:</li> <li>Date, time, and location information</li> <li>Available spots and current signups</li> <li>Detailed shift descriptions</li> <li>One-Click Signup:</li> <li>Enter name, email, and phone number</li> <li>Automatic temporary account creation</li> <li>Instant email confirmation with login details</li> <li>Account Expiration: Temp accounts automatically expire after shift date. (Insert screenshot - public shifts page)</li> </ol>"},{"location":"v1/manual/map/#authenticated-user-shift-management","title":"Authenticated User Shift Management","text":"<ol> <li>Go to Shifts from the main navigation.</li> <li>View Options:</li> <li>Grid View: List format with detailed information</li> <li>Calendar View: Monthly calendar with shift visualization</li> <li>Filter Options: Date range, shift type, and availability status.</li> <li>My Signups: View your confirmed shifts at the top of the page.</li> </ol>"},{"location":"v1/manual/map/#shift-actions","title":"Shift Actions","text":"<ul> <li>Sign Up: Join available shifts (if spots remain)</li> <li>Cancel: Remove yourself from shifts you've joined</li> <li>Calendar Export: Add shifts to Google Calendar, Outlook, or Apple Calendar</li> <li>Shift Details: View full descriptions, requirements, and coordinator info. (Insert screenshot - shifts interface)</li> </ul>"},{"location":"v1/manual/map/#5-advanced-map-features","title":"5. Advanced Map Features","text":""},{"location":"v1/manual/map/#geographic-cuts-system","title":"Geographic Cuts System","text":"<p>What are Cuts?: Polygon overlays that define geographic regions like wards, neighborhoods, or custom areas.</p>"},{"location":"v1/manual/map/#viewing-cuts-all-users","title":"Viewing Cuts (All Users)","text":"<ol> <li>Auto-Display: Public cuts appear automatically when map loads.</li> <li>Multi-Select Control: Desktop users see dropdown with checkboxes for each cut.</li> <li>Mobile Modal: Touch the \ud83d\uddfa\ufe0f button for full-screen cut management.</li> <li>Quick Actions: \"Show All\" / \"Hide All\" buttons for easy control.</li> <li>Color Coding: Each cut has unique colors and opacity settings. (Insert screenshot - cuts display)</li> </ol>"},{"location":"v1/manual/map/#admin-cut-management","title":"Admin Cut Management","text":"<ol> <li>Admin Panel \u2192 Map Cuts for full management interface.</li> <li>Drawing Tools: Click-to-add-points polygon creation system.</li> <li>Cut Properties:</li> <li>Name, description, and category</li> <li>Color and opacity customization</li> <li>Public visibility settings</li> <li>Official designation markers</li> <li>Cut Operations:</li> <li>Create, edit, duplicate, and delete cuts</li> <li>Import/export cut data as JSON</li> <li>Location filtering within cut boundaries</li> <li>Statistics Dashboard: Analyze location data within cut boundaries.</li> <li>Print Functionality: Generate professional reports with maps and data tables. (Insert screenshot - cut management)</li> </ol>"},{"location":"v1/manual/map/#location-filtering-within-cuts","title":"Location Filtering within Cuts","text":"<ol> <li>View Cut: Select a cut from the admin interface.</li> <li>Filter Locations: Automatically shows only locations within cut boundaries.</li> <li>Statistics Panel: Real-time counts of:</li> <li>Total locations within cut</li> <li>Support level breakdown (Strong/Lean/Undecided/Opposition)</li> <li>Contact information availability (email/phone)</li> <li>Lawn sign placements</li> <li>Export Options: Download filtered location data as CSV.</li> <li>Print Reports: Generate professional cut reports with statistics and location tables. (Insert screenshot - cut filtering)</li> </ol>"},{"location":"v1/manual/map/#6-communication-tools","title":"6. Communication Tools","text":""},{"location":"v1/manual/map/#universal-search-contact","title":"Universal Search & Contact","text":"<ol> <li>Ctrl+K Search: Find and contact anyone in your database instantly.</li> <li>Direct Contact Links: Email and phone links throughout the interface.</li> <li>QR Code Generation: Share contact information via QR codes.</li> </ol>"},{"location":"v1/manual/map/#admin-communication-features","title":"Admin Communication Features","text":"<ol> <li>Bulk Email System:</li> <li>Rich HTML email composer with formatting toolbar</li> <li>Live email preview before sending</li> <li>Broadcast to all users with progress tracking</li> <li>Individual delivery status for each recipient</li> <li>One-Click Communication Buttons:</li> <li>\ud83d\udce7 Email: Launch email client with pre-filled recipient</li> <li>\ud83d\udcde Call: Open phone dialer with contact's number</li> <li>\ud83d\udcac SMS: Launch text messaging with contact's number</li> <li>Shift Communication:</li> <li>Email shift details to all volunteers</li> <li>Individual volunteer contact from shift management</li> <li>Automated signup confirmations and reminders. (Insert screenshot - communication tools)</li> </ol>"},{"location":"v1/manual/map/#7-walk-sheet-generator","title":"7. Walk Sheet Generator","text":""},{"location":"v1/manual/map/#creating-walk-sheets","title":"Creating Walk Sheets","text":"<ol> <li>Admin Panel \u2192 Walk Sheet Generator</li> <li>Configuration Options:</li> <li>Title, subtitle, and footer text</li> <li>Contact information and instructions</li> <li>QR codes for digital resources</li> <li>Logo and branding elements</li> <li>Location Selection: Choose specific areas or use cut boundaries.</li> <li>Print Options: Multiple layout formats for different campaign needs.</li> <li>QR Integration: Add QR codes linking to:</li> <li>Digital surveys or forms</li> <li>Contact information</li> <li>Campaign websites or resources. (Insert screenshot - walk sheet generator)</li> </ol>"},{"location":"v1/manual/map/#mobile-optimized-walk-sheets","title":"Mobile-Optimized Walk Sheets","text":"<ol> <li>Responsive Design: Optimized for viewing on phones and tablets.</li> <li>QR Code Scanner Integration: Quick scanning for volunteer check-ins.</li> <li>Offline Capability: Download for use without internet connection.</li> </ol>"},{"location":"v1/manual/map/#8-user-profile-management","title":"8. User Profile Management","text":""},{"location":"v1/manual/map/#personal-settings","title":"Personal Settings","text":"<ol> <li>User Menu \u2192 Profile to access personal settings.</li> <li>Account Information:</li> <li>Update name, email, and phone number</li> <li>Change password</li> <li>Communication preferences</li> <li>Activity History: View your shift signups and location contributions.</li> <li>Privacy Settings: Control data sharing and communication preferences. (Insert screenshot - user profile)</li> </ol>"},{"location":"v1/manual/map/#password-recovery","title":"Password Recovery","text":"<ol> <li>Forgot Password link on login page.</li> <li>Email Reset: Automated password reset via SMTP (if configured).</li> <li>Admin Assistance: Contact administrators for manual password resets.</li> </ol>"},{"location":"v1/manual/map/#9-admin-panel-features","title":"9. Admin Panel Features","text":""},{"location":"v1/manual/map/#dashboard-overview","title":"Dashboard Overview","text":"<ol> <li>System Statistics: User counts, recent activity, and system health.</li> <li>Quick Actions: Direct access to common administrative tasks.</li> <li>NocoDB Integration: Direct links to database management interface. (Insert screenshot - admin dashboard)</li> </ol>"},{"location":"v1/manual/map/#user-management","title":"User Management","text":"<ol> <li>Create Users: Add new accounts with role assignments:</li> <li>Regular Users: Full access to mapping and shifts</li> <li>Temporary Users: Limited access with automatic expiration</li> <li>Admin Users: Full system administration privileges</li> <li>User Communication:</li> <li>Send login details to new users</li> <li>Bulk email all users with rich HTML composer</li> <li>Individual user contact (email, call, text)</li> <li>User Types & Expiration:</li> <li>Set expiration dates for temporary accounts</li> <li>Visual indicators for user types and status</li> <li>Automatic cleanup of expired accounts. (Insert screenshot - user management)</li> </ol>"},{"location":"v1/manual/map/#shift-administration","title":"Shift Administration","text":"<ol> <li>Create & Manage Shifts:</li> <li>Set dates, times, locations, and volunteer limits</li> <li>Public/private visibility settings</li> <li>Detailed descriptions and requirements</li> <li>Volunteer Management:</li> <li>Add users directly to shifts</li> <li>Remove volunteers when needed</li> <li>Email shift details to all participants</li> <li>Generate public signup links</li> <li>Volunteer Communication:</li> <li>Individual contact buttons (email, call, text) for each volunteer</li> <li>Bulk shift detail emails with delivery tracking</li> <li>Automated confirmation and reminder systems. (Insert screenshot - shift management)</li> </ol>"},{"location":"v1/manual/map/#system-configuration","title":"System Configuration","text":"<ol> <li>Map Settings:</li> <li>Set default start location and zoom level</li> <li>Configure map boundaries and restrictions</li> <li>Customize marker styles and colors</li> <li>Integration Management:</li> <li>NocoDB database connections</li> <li>Listmonk email list synchronization</li> <li>SMTP configuration for automated emails</li> <li>Security Settings:</li> <li>User permissions and role management</li> <li>API access controls</li> <li>Session management. (Insert screenshot - system config)</li> </ol>"},{"location":"v1/manual/map/#10-data-management-integration","title":"10. Data Management & Integration","text":""},{"location":"v1/manual/map/#nocodb-database-integration","title":"NocoDB Database Integration","text":"<ol> <li>Direct Database Access: Admin links to NocoDB sheets for advanced data management.</li> <li>Automated Sync: Real-time synchronization between map interface and database.</li> <li>Backup & Migration: Built-in tools for data backup and system migration.</li> <li>Custom Fields: Add custom data fields through NocoDB interface.</li> </ol>"},{"location":"v1/manual/map/#listmonk-email-marketing-integration","title":"Listmonk Email Marketing Integration","text":"<ol> <li>Automatic List Sync: Map data automatically syncs to Listmonk email lists.</li> <li>Segmentation: Create targeted lists based on:</li> <li>Geographic location (cuts/neighborhoods)</li> <li>Support levels and volunteer interest</li> <li>Contact preferences and activity</li> <li>One-Direction Sync: Maintains data integrity while allowing email unsubscribes.</li> <li>Compliance: Newsletter legislation compliance with opt-out capabilities. (Insert screenshot - integration settings)</li> </ol>"},{"location":"v1/manual/map/#data-export-reporting","title":"Data Export & Reporting","text":"<ol> <li>CSV Export: Download location data, user lists, and shift reports.</li> <li>Cut Reports: Professional reports with statistics and location breakdowns.</li> <li>Print-Ready Formats: Optimized layouts for physical distribution.</li> <li>Analytics Dashboard: Track user engagement and system usage.</li> </ol>"},{"location":"v1/manual/map/#11-mobile-accessibility-features","title":"11. Mobile & Accessibility Features","text":""},{"location":"v1/manual/map/#mobile-optimized-interface","title":"Mobile-Optimized Interface","text":"<ol> <li>Responsive Design: Fully functional on phones and tablets.</li> <li>Touch Navigation: Optimized touch controls for map interaction.</li> <li>Mobile-Specific Features:</li> <li>Cut management modal for overlay control</li> <li>Simplified navigation and larger touch targets</li> <li>Offline capability for basic functions</li> </ol>"},{"location":"v1/manual/map/#accessibility","title":"Accessibility","text":"<ol> <li>Keyboard Navigation: Full keyboard support throughout the interface.</li> <li>Screen Reader Compatibility: ARIA labels and semantic markup.</li> <li>High Contrast Support: Compatible with accessibility themes.</li> <li>Text Scaling: Responsive to browser zoom and text size settings.</li> </ol>"},{"location":"v1/manual/map/#12-security-privacy","title":"12. Security & Privacy","text":""},{"location":"v1/manual/map/#data-protection","title":"Data Protection","text":"<ol> <li>Server-Side Security: All API tokens and credentials kept server-side only.</li> <li>Input Validation: Comprehensive validation and sanitization of all user inputs.</li> <li>CORS Protection: Cross-origin request security measures.</li> <li>Rate Limiting: Protection against abuse and automated attacks.</li> </ol>"},{"location":"v1/manual/map/#user-privacy","title":"User Privacy","text":"<ol> <li>Role-Based Access: Users only see data appropriate to their permission level.</li> <li>Temporary Account Expiration: Automatic cleanup of temporary user data.</li> <li>Audit Trails: Logging of administrative actions and data changes.</li> <li>Data Retention: Configurable retention policies for different data types. (Insert screenshot - security settings)</li> </ol>"},{"location":"v1/manual/map/#authentication","title":"Authentication","text":"<ol> <li>Secure Login: Password-based authentication with optional 2FA.</li> <li>Session Management: Automatic logout for expired sessions.</li> <li>Password Policies: Configurable password strength requirements.</li> <li>Account Lockout: Protection against brute force attacks.</li> </ol>"},{"location":"v1/manual/map/#13-performance-system-requirements","title":"13. Performance & System Requirements","text":""},{"location":"v1/manual/map/#system-performance","title":"System Performance","text":"<ol> <li>Optimized Database Queries: Reduced API calls by over 5000% for better performance.</li> <li>Smart Caching: Intelligent caching of frequently accessed data.</li> <li>Progressive Loading: Map data loads incrementally for faster initial page loads.</li> <li>Background Sync: Automatic data synchronization without blocking user interface.</li> </ol>"},{"location":"v1/manual/map/#browser-requirements","title":"Browser Requirements","text":"<ol> <li>Modern Browsers: Chrome, Firefox, Safari, Edge (recent versions).</li> <li>JavaScript Required: Full functionality requires JavaScript enabled.</li> <li>Local Storage: Uses browser storage for session management and caching.</li> <li>Geolocation: Optional location services for enhanced functionality.</li> </ol>"},{"location":"v1/manual/map/#14-troubleshooting","title":"14. Troubleshooting","text":""},{"location":"v1/manual/map/#common-issues","title":"Common Issues","text":"<ul> <li>Locations not showing: Check database connectivity, verify coordinates are valid, ensure API permissions allow read access.</li> <li>Cannot add locations: Verify API write permissions, check coordinate bounds, ensure all required fields completed.</li> <li>Login problems: Verify email/password, check account expiration (for temp users), contact admin for password reset.</li> <li>Map not loading: Check internet connection, verify site URL, clear browser cache and cookies.</li> <li>Permission denied: Confirm user role and permissions, check account expiration status, contact administrator.</li> </ul>"},{"location":"v1/manual/map/#performance-issues","title":"Performance Issues","text":"<ul> <li>Slow loading: Check internet connection, try refreshing the page, contact admin if problems persist.</li> <li>Database errors: Contact system administrator, check NocoDB service status.</li> <li>Email not working: Verify SMTP configuration (admin), check spam/junk folders.</li> </ul>"},{"location":"v1/manual/map/#mobile-issues","title":"Mobile Issues","text":"<ul> <li>Touch problems: Ensure touch targets are accessible, try refreshing page, check for browser compatibility.</li> <li>Display issues: Try rotating device, check browser zoom level, update to latest browser version.</li> </ul>"},{"location":"v1/manual/map/#15-advanced-features","title":"15. Advanced Features","text":""},{"location":"v1/manual/map/#api-access","title":"API Access","text":"<ol> <li>RESTful API: Programmatic access to map data and functionality.</li> <li>Authentication: Token-based API authentication for external integrations.</li> <li>Rate Limiting: API usage limits to ensure system stability.</li> <li>Documentation: Complete API documentation for developers.</li> </ol>"},{"location":"v1/manual/map/#customization-options","title":"Customization Options","text":"<ol> <li>Theming: Customizable color schemes and branding.</li> <li>Field Configuration: Add custom data fields through admin interface.</li> <li>Workflow Customization: Configurable user workflows and permissions.</li> <li>Integration Hooks: Webhook support for external system integration.</li> </ol>"},{"location":"v1/manual/map/#16-getting-help-support","title":"16. Getting Help & Support","text":""},{"location":"v1/manual/map/#built-in-help","title":"Built-in Help","text":"<ol> <li>Context Help: Tooltips and help text throughout the interface.</li> <li>Search Documentation: Use Ctrl+K to search help articles and guides.</li> <li>Status Messages: Clear feedback for all user actions and system status.</li> </ol>"},{"location":"v1/manual/map/#administrator-support","title":"Administrator Support","text":"<ol> <li>Contact Admin: Use the contact information provided during setup.</li> <li>System Logs: Administrators have access to detailed system logs for troubleshooting.</li> <li>Database Direct Access: Admins can access NocoDB directly for advanced data management.</li> </ol>"},{"location":"v1/manual/map/#community-resources","title":"Community Resources","text":"<ol> <li>Documentation: Comprehensive online documentation and guides.</li> <li>GitHub Repository: Access to source code and issue tracking.</li> <li>Developer Community: Active community for advanced customization and development.</li> </ol> <p>For technical support, contact your system administrator or refer to the comprehensive documentation available through the help system. (Insert screenshot - help resources)</p>"},{"location":"v1/services/","title":"Services","text":"<p>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.</p>"},{"location":"v1/services/#available-services","title":"Available Services","text":""},{"location":"v1/services/#code-server","title":"Code Server","text":"<p>Port: 8888 | Visual Studio Code in your browser for remote development</p> <ul> <li>Full IDE experience</li> <li>Extensions support</li> <li>Git integration</li> <li>Terminal access</li> </ul>"},{"location":"v1/services/#listmonk","title":"Listmonk","text":"<p>Port: 9000 | Self-hosted newsletter and mailing list manager</p> <ul> <li>Email campaigns</li> <li>Subscriber management</li> <li>Analytics</li> <li>Template system</li> </ul>"},{"location":"v1/services/#postgresql","title":"PostgreSQL","text":"<p>Port: 5432 | Reliable database backend - Data persistence for Listmonk - ACID compliance - High performance - Backup and restore capabilities</p>"},{"location":"v1/services/#mkdocs-material","title":"MkDocs Material","text":"<p>Port: 4000 | Documentation site generator with live preview</p> <ul> <li>Material Design theme</li> <li>Live reload</li> <li>Search functionality</li> <li>Markdown support</li> </ul>"},{"location":"v1/services/#static-site-server","title":"Static Site Server","text":"<p>Port: 4001 | Nginx-powered static site hosting - High-performance serving - Built documentation hosting - Caching and compression - Security headers</p>"},{"location":"v1/services/#n8n","title":"n8n","text":"<p>Port: 5678 | Workflow automation tool</p> <ul> <li>Visual workflow editor</li> <li>400+ integrations</li> <li>Custom code execution</li> <li>Webhook support</li> </ul>"},{"location":"v1/services/#nocodb","title":"NocoDB","text":"<p>Port: 8090 | No-code database platform</p> <ul> <li>Smart spreadsheet interface</li> <li>Form builder and API generation</li> <li>Real-time collaboration</li> <li>Multi-database support</li> </ul>"},{"location":"v1/services/#homepage","title":"Homepage","text":"<p>Port: 3010 | Modern dashboard for all services</p> <ul> <li>Service dashboard and monitoring</li> <li>Docker integration</li> <li>Customizable layout</li> <li>Quick search and bookmarks</li> </ul>"},{"location":"v1/services/#gitea","title":"Gitea","text":"<p>Port: 3030 | Self-hosted Git service</p> <ul> <li>Git repository hosting</li> <li>Web-based interface</li> <li>Issue tracking</li> <li>Pull requests</li> <li>Wiki and code review</li> <li>Lightweight and easy to deploy</li> </ul>"},{"location":"v1/services/#mini-qr","title":"Mini QR","text":"<p>Port: 8089 | Simple QR code generator service</p> <ul> <li>Generate QR codes for text or URLs</li> <li>Download QR codes as images</li> <li>Simple and fast interface</li> <li>No user registration required</li> </ul>"},{"location":"v1/services/#map","title":"Map","text":"<p>Port: 3000 | Canvassing and community organizing application </p> <ul> <li>Interactive map for door-to-door canvassing</li> <li>Location and contact management</li> <li>Admin panel and QR code walk sheets</li> <li>NocoDB integration for data storage</li> <li>User authentication and access control</li> </ul>"},{"location":"v1/services/#service-architecture","title":"Service Architecture","text":"<pre><code>\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</code></pre>"},{"location":"v1/services/code-server/","title":"Code Server","text":""},{"location":"v1/services/code-server/#overview","title":"Overview","text":"<p>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.</p>"},{"location":"v1/services/code-server/#features","title":"Features","text":"<ul> <li>Full VS Code experience in the browser</li> <li>Extensions support</li> <li>Terminal access</li> <li>Git integration</li> <li>File editing and management</li> <li>Multi-language support</li> </ul>"},{"location":"v1/services/code-server/#access","title":"Access","text":"<ul> <li>Default Port: 8888</li> <li>URL: <code>http://localhost:8888</code></li> <li>Default Workspace: <code>/home/coder/mkdocs/</code></li> </ul>"},{"location":"v1/services/code-server/#configuration","title":"Configuration","text":""},{"location":"v1/services/code-server/#environment-variables","title":"Environment Variables","text":"<ul> <li><code>DOCKER_USER</code>: The user to run code-server as (default: <code>coder</code>)</li> <li><code>DEFAULT_WORKSPACE</code>: Default workspace directory</li> <li><code>USER_ID</code>: User ID for file permissions</li> <li><code>GROUP_ID</code>: Group ID for file permissions</li> </ul>"},{"location":"v1/services/code-server/#volumes","title":"Volumes","text":"<ul> <li><code>./configs/code-server/.config</code>: VS Code configuration</li> <li><code>./configs/code-server/.local</code>: Local data</li> <li><code>./mkdocs</code>: Main workspace directory</li> </ul>"},{"location":"v1/services/code-server/#usage","title":"Usage","text":"<ol> <li>Access Code Server at <code>http://localhost:8888</code></li> <li>Open the <code>/home/coder/mkdocs/</code> workspace</li> <li>Start editing your documentation files</li> <li>Install extensions as needed</li> <li>Use the integrated terminal for commands</li> </ol>"},{"location":"v1/services/code-server/#useful-extensions","title":"Useful Extensions","text":"<p>Consider installing these extensions for better documentation work:</p> <ul> <li>Markdown All in One</li> <li>Material Design Icons</li> <li>GitLens</li> <li>Docker</li> <li>YAML</li> </ul>"},{"location":"v1/services/code-server/#official-documentation","title":"Official Documentation","text":"<p>For more detailed information, visit the official Code Server documentation.</p>"},{"location":"v1/services/gitea/","title":"Gitea","text":"<p>Self-hosted Git service for collaborative development.</p>"},{"location":"v1/services/gitea/#overview","title":"Overview","text":"<p>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.</p>"},{"location":"v1/services/gitea/#features","title":"Features","text":"<ul> <li>Git repository hosting</li> <li>Web-based interface</li> <li>Issue tracking</li> <li>Pull requests</li> <li>Wiki and code review</li> <li>Lightweight and easy to deploy</li> </ul>"},{"location":"v1/services/gitea/#access","title":"Access","text":"<ul> <li>Default Web Port: <code>${GITEA_WEB_PORT:-3030}</code> (default: 3030)</li> <li>Default SSH Port: <code>${GITEA_SSH_PORT:-2222}</code> (default: 2222)</li> <li>URL: <code>http://localhost:${GITEA_WEB_PORT:-3030}</code></li> <li>Default Data Directory: <code>/data/gitea</code></li> </ul>"},{"location":"v1/services/gitea/#configuration","title":"Configuration","text":""},{"location":"v1/services/gitea/#environment-variables","title":"Environment Variables","text":"<ul> <li><code>GITEA__database__DB_TYPE</code>: Database type (e.g., <code>sqlite3</code>, <code>mysql</code>, <code>postgres</code>)</li> <li><code>GITEA__database__HOST</code>: Database host (default: <code>${GITEA_DB_HOST:-gitea-db:3306}</code>)</li> <li><code>GITEA__database__NAME</code>: Database name (default: <code>${GITEA_DB_NAME:-gitea}</code>)</li> <li><code>GITEA__database__USER</code>: Database user (default: <code>${GITEA_DB_USER:-gitea}</code>)</li> <li><code>GITEA__database__PASSWD</code>: Database password (from <code>.env</code>)</li> <li><code>GITEA__server__ROOT_URL</code>: Root URL (e.g., <code>${GITEA_ROOT_URL}</code>)</li> <li><code>GITEA__server__HTTP_PORT</code>: Web port (default: 3000 inside container)</li> <li><code>GITEA__server__DOMAIN</code>: Domain (e.g., <code>${GITEA_DOMAIN}</code>)</li> </ul>"},{"location":"v1/services/gitea/#volumes","title":"Volumes","text":"<ul> <li><code>gitea_data:/data</code>: Gitea configuration and data</li> <li><code>/etc/timezone:/etc/timezone:ro</code></li> <li><code>/etc/localtime:/etc/localtime:ro</code></li> </ul>"},{"location":"v1/services/gitea/#usage","title":"Usage","text":"<ol> <li>Access Gitea at <code>http://localhost:${GITEA_WEB_PORT:-3030}</code></li> <li>Register or log in as an admin user</li> <li>Create or import repositories</li> <li>Collaborate with your team</li> </ol>"},{"location":"v1/services/gitea/#official-documentation","title":"Official Documentation","text":"<p>For more details, visit the official Gitea documentation.</p>"},{"location":"v1/services/homepage/","title":"Homepage","text":"<p>Modern dashboard for accessing all your self-hosted services.</p>"},{"location":"v1/services/homepage/#overview","title":"Overview","text":"<p>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.</p>"},{"location":"v1/services/homepage/#features","title":"Features","text":"<ul> <li>Service Dashboard: Central hub for all your applications</li> <li>Docker Integration: Automatic service discovery and monitoring</li> <li>Customizable Layout: Flexible grid-based layout system</li> <li>Service Widgets: Live status and metrics for services</li> <li>Quick Search: Fast navigation with built-in search</li> <li>Bookmarks: Organize frequently used links</li> <li>Dark/Light Themes: Multiple color schemes available</li> <li>Responsive Design: Works on desktop and mobile devices</li> </ul>"},{"location":"v1/services/homepage/#access","title":"Access","text":"<ul> <li>Default Port: 3010</li> <li>URL: <code>http://localhost:3010</code></li> <li>Configuration: YAML-based configuration files</li> </ul>"},{"location":"v1/services/homepage/#configuration","title":"Configuration","text":""},{"location":"v1/services/homepage/#environment-variables","title":"Environment Variables","text":"<ul> <li><code>HOMEPAGE_PORT</code>: External port mapping (default: 3010)</li> <li><code>PUID</code>: User ID for file permissions (default: 1000)</li> <li><code>PGID</code>: Group ID for file permissions (default: 1000)</li> <li><code>TZ</code>: Timezone setting (default: Etc/UTC)</li> <li><code>HOMEPAGE_ALLOWED_HOSTS</code>: Allowed hosts for the dashboard</li> </ul>"},{"location":"v1/services/homepage/#configuration-files","title":"Configuration Files","text":"<p>Homepage uses YAML configuration files located in <code>./configs/homepage/</code>:</p> <ul> <li><code>settings.yaml</code>: Global settings and theme configuration</li> <li><code>services.yaml</code>: Service definitions and widgets</li> <li><code>bookmarks.yaml</code>: Bookmark categories and links</li> <li><code>widgets.yaml</code>: Dashboard widgets configuration</li> <li><code>docker.yaml</code>: Docker integration settings</li> </ul>"},{"location":"v1/services/homepage/#volumes","title":"Volumes","text":"<ul> <li><code>./configs/homepage:/app/config</code>: Configuration files</li> <li><code>./assets/icons:/app/public/icons</code>: Custom service icons</li> <li><code>./assets/images:/app/public/images</code>: Background images and assets</li> <li><code>/var/run/docker.sock:/var/run/docker.sock</code>: Docker socket for container monitoring</li> </ul>"},{"location":"v1/services/homepage/#changemaker-lite-services","title":"Changemaker Lite Services","text":"<p>Homepage is pre-configured with all Changemaker Lite services:</p>"},{"location":"v1/services/homepage/#essential-tools","title":"Essential Tools","text":"<ul> <li>Code Server (Port 8888): VS Code in the browser</li> <li>Listmonk (Port 9000): Newsletter & mailing list manager</li> <li>NocoDB (Port 8090): No-code database platform</li> </ul>"},{"location":"v1/services/homepage/#content-documentation","title":"Content & Documentation","text":"<ul> <li>MkDocs (Port 4000): Live documentation server</li> <li>Static Site (Port 4001): Built documentation hosting</li> </ul>"},{"location":"v1/services/homepage/#automation-data","title":"Automation & Data","text":"<ul> <li>n8n (Port 5678): Workflow automation platform</li> <li>PostgreSQL (Port 5432): Database backends</li> </ul>"},{"location":"v1/services/homepage/#customization","title":"Customization","text":""},{"location":"v1/services/homepage/#adding-custom-services","title":"Adding Custom Services","text":"<p>Edit <code>configs/homepage/services.yaml</code> to add new services:</p> <pre><code>- 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</code></pre>"},{"location":"v1/services/homepage/#custom-icons","title":"Custom Icons","text":"<p>Add custom icons to <code>./assets/icons/</code> directory and reference them in services.yaml:</p> <pre><code>icon: /icons/my-custom-icon.png\n</code></pre>"},{"location":"v1/services/homepage/#themes-and-styling","title":"Themes and Styling","text":"<p>Modify <code>configs/homepage/settings.yaml</code> to customize appearance:</p> <pre><code>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</code></pre>"},{"location":"v1/services/homepage/#widgets","title":"Widgets","text":"<p>Enable live monitoring widgets in <code>configs/homepage/services.yaml</code>:</p> <pre><code>- Service Name:\n widget:\n type: docker\n container: container-name\n server: my-docker\n</code></pre>"},{"location":"v1/services/homepage/#service-monitoring","title":"Service Monitoring","text":"<p>Homepage can display real-time status information for your services:</p> <ul> <li>Docker Integration: Container status and resource usage</li> <li>HTTP Ping: Service availability monitoring</li> <li>Custom APIs: Integration with service-specific APIs</li> </ul>"},{"location":"v1/services/homepage/#docker-integration","title":"Docker Integration","text":"<p>Homepage monitors Docker containers automatically when configured:</p> <ol> <li>Ensure Docker socket is mounted (<code>/var/run/docker.sock</code>)</li> <li>Configure container mappings in <code>docker.yaml</code></li> <li>Add widget configurations to <code>services.yaml</code></li> </ol>"},{"location":"v1/services/homepage/#security-considerations","title":"Security Considerations","text":"<ul> <li>Homepage runs with limited privileges</li> <li>Configuration files should have appropriate permissions</li> <li>Consider network isolation for production deployments</li> <li>Use HTTPS for external access</li> <li>Regularly update the Homepage image</li> </ul>"},{"location":"v1/services/homepage/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v1/services/homepage/#common-issues","title":"Common Issues","text":"<p>Configuration not loading: Check YAML syntax in configuration files</p> <pre><code>docker logs homepage-changemaker\n</code></pre> <p>Icons not displaying: Verify icon paths and file permissions</p> <pre><code>ls -la ./assets/icons/\n</code></pre> <p>Services not reachable: Verify network connectivity between containers</p> <pre><code>docker exec homepage-changemaker ping service-name\n</code></pre> <p>Widget data not updating: Check Docker socket permissions and container access</p> <pre><code>docker exec homepage-changemaker ls -la /var/run/docker.sock\n</code></pre>"},{"location":"v1/services/homepage/#configuration-examples","title":"Configuration Examples","text":""},{"location":"v1/services/homepage/#basic-service-widget","title":"Basic Service Widget","text":"<pre><code>- 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</code></pre>"},{"location":"v1/services/homepage/#custom-dashboard-layout","title":"Custom Dashboard Layout","text":"<pre><code># settings.yaml\nlayout:\n style: columns\n columns: 3\n\n# Responsive breakpoints\nresponsive:\n mobile: 1\n tablet: 2\n desktop: 3\n</code></pre>"},{"location":"v1/services/homepage/#official-documentation","title":"Official Documentation","text":"<p>For comprehensive configuration guides and advanced features:</p> <ul> <li>Homepage Documentation</li> <li>GitHub Repository</li> <li>Configuration Examples</li> <li>Widget Integrations</li> </ul>"},{"location":"v1/services/listmonk/","title":"Listmonk","text":"<p>Self-hosted newsletter and mailing list manager.</p>"},{"location":"v1/services/listmonk/#overview","title":"Overview","text":"<p>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.</p>"},{"location":"v1/services/listmonk/#features","title":"Features","text":"<ul> <li>Newsletter and email campaign management</li> <li>Subscriber list management</li> <li>Template system with HTML/markdown support</li> <li>Campaign analytics and tracking</li> <li>API for integration</li> <li>Multi-list support</li> <li>Bounce handling</li> <li>Privacy-focused design</li> </ul>"},{"location":"v1/services/listmonk/#access","title":"Access","text":"<ul> <li>Default Port: 9000</li> <li>URL: <code>http://localhost:9000</code></li> <li>Admin User: Set via <code>LISTMONK_ADMIN_USER</code> environment variable</li> <li>Admin Password: Set via <code>LISTMONK_ADMIN_PASSWORD</code> environment variable</li> </ul>"},{"location":"v1/services/listmonk/#configuration","title":"Configuration","text":""},{"location":"v1/services/listmonk/#environment-variables","title":"Environment Variables","text":"<ul> <li><code>LISTMONK_ADMIN_USER</code>: Admin username</li> <li><code>LISTMONK_ADMIN_PASSWORD</code>: Admin password</li> <li><code>POSTGRES_USER</code>: Database username</li> <li><code>POSTGRES_PASSWORD</code>: Database password</li> <li><code>POSTGRES_DB</code>: Database name</li> </ul>"},{"location":"v1/services/listmonk/#database","title":"Database","text":"<p>Listmonk uses PostgreSQL as its backend database. The database is automatically configured through the docker-compose setup.</p>"},{"location":"v1/services/listmonk/#uploads","title":"Uploads","text":"<ul> <li>Upload directory: <code>./assets/uploads</code></li> <li>Used for media files, templates, and attachments</li> </ul>"},{"location":"v1/services/listmonk/#getting-started","title":"Getting Started","text":"<ol> <li>Access Listmonk at <code>http://localhost:9000</code></li> <li>Log in with your admin credentials</li> <li>Set up your first mailing list</li> <li>Configure SMTP settings for sending emails</li> <li>Import subscribers or create subscription forms</li> <li>Create your first campaign</li> </ol>"},{"location":"v1/services/listmonk/#important-notes","title":"Important Notes","text":"<ul> <li>Configure SMTP settings before sending emails</li> <li>Set up proper domain authentication (SPF, DKIM) for better deliverability</li> <li>Regularly backup your subscriber data and campaigns</li> <li>Monitor bounce rates and maintain list hygiene</li> </ul>"},{"location":"v1/services/listmonk/#official-documentation","title":"Official Documentation","text":"<p>For comprehensive guides and API documentation, visit: - Listmonk Documentation - GitHub Repository</p>"},{"location":"v1/services/map/","title":"Map","text":"<p>Interactive map service for geospatial data visualization, powered by NocoDB and Leaflet.js.</p>"},{"location":"v1/services/map/#overview","title":"Overview","text":"<p>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.</p>"},{"location":"v1/services/map/#features","title":"Features","text":"<ul> <li>Interactive map visualization with OpenStreetMap</li> <li>Real-time geolocation support</li> <li>Add new locations directly from the map</li> <li>Auto-refresh every 30 seconds</li> <li>Responsive design for mobile devices</li> <li>Secure API proxy to protect credentials</li> <li>Docker containerization for easy deployment</li> </ul>"},{"location":"v1/services/map/#access","title":"Access","text":"<ul> <li>Default Port: <code>${MAP_PORT:-3000}</code> (default: 3000)</li> <li>URL: <code>http://localhost:${MAP_PORT:-3000}</code></li> <li>Default Workspace: <code>/app/public/</code></li> </ul>"},{"location":"v1/services/map/#configuration","title":"Configuration","text":"<p>All configuration is done via environment variables:</p> Variable Description Default <code>NOCODB_API_URL</code> NocoDB API base URL Required <code>NOCODB_API_TOKEN</code> API authentication token Required <code>NOCODB_VIEW_URL</code> Full NocoDB view URL Required <code>PORT</code> Server port 3000 <code>DEFAULT_LAT</code> Default map latitude 53.5461 <code>DEFAULT_LNG</code> Default map longitude -113.4938 <code>DEFAULT_ZOOM</code> Default map zoom level 11"},{"location":"v1/services/map/#volumes","title":"Volumes","text":"<ul> <li><code>./map/app/public</code>: Map public assets</li> </ul>"},{"location":"v1/services/map/#usage","title":"Usage","text":"<ol> <li>Access the map at <code>http://localhost:${MAP_PORT:-3000}</code></li> <li>Search for locations or addresses</li> <li>Add or view custom markers</li> <li>Analyze geospatial data as needed</li> </ol>"},{"location":"v1/services/map/#nocodb-table-setup","title":"NocoDB Table Setup","text":""},{"location":"v1/services/map/#required-columns","title":"Required Columns","text":"<ul> <li><code>geodata</code> (Text): Format \"latitude;longitude\" </li> <li><code>latitude</code> (Decimal): Precision 10, Scale 8</li> <li><code>longitude</code> (Decimal): Precision 11, Scale 8</li> </ul>"},{"location":"v1/services/map/#form-fields-as-seen-in-the-interface","title":"Form Fields (as seen in the interface)","text":"<ul> <li><code>First Name</code> (Text): Person's first name</li> <li><code>Last Name</code> (Text): Person's last name </li> <li><code>Email</code> (Email): Contact email address</li> <li><code>Unit Number</code> (Text): Apartment/unit number</li> <li><code>Support Level</code> (Single Select): </li> <li>1 - Strong Support (Green)</li> <li>2 - Moderate Support (Yellow) </li> <li>3 - Low Support (Orange)</li> <li>4 - No Support (Red)</li> <li><code>Address</code> (Text): Full street address</li> <li><code>Sign</code> (Checkbox): Has campaign sign (true/false)</li> <li><code>Sign Size</code> (Single Select): Small, Medium, Large</li> <li><code>Geo-Location</code> (Text): Formatted as \"latitude;longitude\"</li> </ul>"},{"location":"v1/services/map/#api-endpoints","title":"API Endpoints","text":"<ul> <li><code>GET /api/locations</code> - Fetch all locations</li> <li><code>POST /api/locations</code> - Create new location</li> <li><code>GET /api/locations/:id</code> - Get single location</li> <li><code>PUT /api/locations/:id</code> - Update location</li> <li><code>DELETE /api/locations/:id</code> - Delete location</li> <li><code>GET /health</code> - Health check</li> </ul>"},{"location":"v1/services/map/#security-considerations","title":"Security Considerations","text":"<ul> <li>API tokens are kept server-side only</li> <li>CORS is configured for security</li> <li>Rate limiting prevents abuse</li> <li>Input validation on all endpoints</li> <li>Helmet.js for security headers</li> </ul>"},{"location":"v1/services/map/#troubleshooting","title":"Troubleshooting","text":"<ul> <li>Ensure NocoDB table has required columns and valid coordinates</li> <li>Check API token permissions and network connectivity</li> </ul>"},{"location":"v1/services/mini-qr/","title":"Mini QR","text":"<p>Simple QR code generator service.</p>"},{"location":"v1/services/mini-qr/#overview","title":"Overview","text":"<p>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.</p>"},{"location":"v1/services/mini-qr/#features","title":"Features","text":"<ul> <li>Generate QR codes for text or URLs</li> <li>Download QR codes as images</li> <li>Simple and fast interface</li> <li>No user registration required</li> </ul>"},{"location":"v1/services/mini-qr/#access","title":"Access","text":"<ul> <li>Default Port: <code>${MINI_QR_PORT:-8089}</code> (default: 8089)</li> <li>URL: <code>http://localhost:${MINI_QR_PORT:-8089}</code></li> </ul>"},{"location":"v1/services/mini-qr/#configuration","title":"Configuration","text":""},{"location":"v1/services/mini-qr/#environment-variables","title":"Environment Variables","text":"<ul> <li><code>QR_DEFAULT_SIZE</code>: Default size of generated QR codes</li> <li><code>QR_IMAGE_FORMAT</code>: Image format (e.g., <code>png</code>, <code>svg</code>)</li> </ul>"},{"location":"v1/services/mini-qr/#volumes","title":"Volumes","text":"<ul> <li><code>./configs/mini-qr</code>: QR code service configuration</li> </ul>"},{"location":"v1/services/mini-qr/#usage","title":"Usage","text":"<ol> <li>Access Mini QR at <code>http://localhost:${MINI_QR_PORT:-8089}</code></li> <li>Enter the text or URL to encode</li> <li>Download or share the generated QR code</li> </ol>"},{"location":"v1/services/mkdocs/","title":"MkDocs Material","text":"<p>Modern documentation site generator with live preview. </p> <p>Looking for more info on BNKops code-server integration? </p> <p>\u2192 Code Server Configuration</p>"},{"location":"v1/services/mkdocs/#overview","title":"Overview","text":"<p>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.</p>"},{"location":"v1/services/mkdocs/#features","title":"Features","text":"<ul> <li>Material Design theme</li> <li>Live preview during development</li> <li>Search functionality</li> <li>Navigation and organization</li> <li>Code syntax highlighting</li> <li>Mathematical expressions support</li> <li>Responsive design</li> <li>Customizable themes and colors</li> </ul>"},{"location":"v1/services/mkdocs/#access","title":"Access","text":"<ul> <li>Development Port: 4000</li> <li>Development URL: <code>http://localhost:4000</code></li> <li>Live Reload: Automatically refreshes on file changes</li> </ul>"},{"location":"v1/services/mkdocs/#configuration","title":"Configuration","text":""},{"location":"v1/services/mkdocs/#main-configuration","title":"Main Configuration","text":"<p>Configuration is managed through <code>mkdocs.yml</code> in the project root.</p>"},{"location":"v1/services/mkdocs/#volumes","title":"Volumes","text":"<ul> <li><code>./mkdocs</code>: Documentation source files</li> <li><code>./assets/images</code>: Shared images directory</li> </ul>"},{"location":"v1/services/mkdocs/#environment-variables","title":"Environment Variables","text":"<ul> <li><code>SITE_URL</code>: Base domain for the site</li> <li><code>USER_ID</code>: User ID for file permissions</li> <li><code>GROUP_ID</code>: Group ID for file permissions</li> </ul>"},{"location":"v1/services/mkdocs/#directory-structure","title":"Directory Structure","text":"<pre><code>mkdocs/\n\u251c\u2500\u2500 mkdocs.yml # Configuration file\n\u251c\u2500\u2500 docs/ # Documentation source\n\u2502 \u251c\u2500\u2500 index.md # Homepage\n\u2502 \u251c\u2500\u2500 services/ # Service documentation\n\u2502 \u251c\u2500\u2500 blog/ # Blog posts\n\u2502 \u2514\u2500\u2500 overrides/ # Template overrides\n\u2514\u2500\u2500 site/ # Built static site\n</code></pre>"},{"location":"v1/services/mkdocs/#writing-documentation","title":"Writing Documentation","text":""},{"location":"v1/services/mkdocs/#markdown-basics","title":"Markdown Basics","text":"<ul> <li>Use standard Markdown syntax</li> <li>Support for tables, code blocks, and links</li> <li>Mathematical expressions with MathJax</li> <li>Admonitions for notes and warnings</li> </ul>"},{"location":"v1/services/mkdocs/#example-page","title":"Example Page","text":"<pre><code># 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</code></pre> <p>Note</p> <p>This is an informational note.</p> <pre><code>## 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</code></pre> <p>The built site will be available in the <code>mkdocs/site/</code> directory.</p>"},{"location":"v1/services/mkdocs/#customization","title":"Customization","text":""},{"location":"v1/services/mkdocs/#themes-and-colors","title":"Themes and Colors","text":"<p>Customize appearance in <code>mkdocs.yml</code>:</p> <pre><code>theme:\n name: material\n palette:\n primary: blue\n accent: indigo\n</code></pre>"},{"location":"v1/services/mkdocs/#custom-css","title":"Custom CSS","text":"<p>Add custom styles in <code>docs/stylesheets/extra.css</code>.</p>"},{"location":"v1/services/mkdocs/#official-documentation","title":"Official Documentation","text":"<p>For comprehensive MkDocs Material documentation: - MkDocs Material - MkDocs Documentation - Markdown Guide</p>"},{"location":"v1/services/n8n/","title":"n8n","text":"<p>Workflow automation tool for connecting services and automating tasks.</p>"},{"location":"v1/services/n8n/#overview","title":"Overview","text":"<p>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.</p>"},{"location":"v1/services/n8n/#features","title":"Features","text":"<ul> <li>Visual workflow editor</li> <li>400+ integrations</li> <li>Custom code execution (JavaScript/Python)</li> <li>Webhook support</li> <li>Scheduled workflows</li> <li>Error handling and retries</li> <li>User management</li> <li>API access</li> <li>Self-hosted and privacy-focused</li> </ul>"},{"location":"v1/services/n8n/#access","title":"Access","text":"<ul> <li>Default Port: 5678</li> <li>URL: <code>http://localhost:5678</code></li> <li>Default User Email: Set via <code>N8N_DEFAULT_USER_EMAIL</code></li> <li>Default User Password: Set via <code>N8N_DEFAULT_USER_PASSWORD</code></li> </ul>"},{"location":"v1/services/n8n/#configuration","title":"Configuration","text":""},{"location":"v1/services/n8n/#environment-variables","title":"Environment Variables","text":"<ul> <li><code>N8N_HOST</code>: Hostname for n8n (default: <code>n8n.${DOMAIN}</code>)</li> <li><code>N8N_PORT</code>: Internal port (5678)</li> <li><code>N8N_PROTOCOL</code>: Protocol for webhooks (https)</li> <li><code>NODE_ENV</code>: Environment (production)</li> <li><code>WEBHOOK_URL</code>: Base URL for webhooks</li> <li><code>GENERIC_TIMEZONE</code>: Timezone setting</li> <li><code>N8N_ENCRYPTION_KEY</code>: Encryption key for credentials</li> <li><code>N8N_USER_MANAGEMENT_DISABLED</code>: Enable/disable user management</li> <li><code>N8N_DEFAULT_USER_EMAIL</code>: Default admin email</li> <li><code>N8N_DEFAULT_USER_PASSWORD</code>: Default admin password</li> </ul>"},{"location":"v1/services/n8n/#volumes","title":"Volumes","text":"<ul> <li><code>n8n_data</code>: Persistent data storage</li> <li><code>./local-files</code>: Local file access for workflows</li> </ul>"},{"location":"v1/services/n8n/#getting-started","title":"Getting Started","text":"<ol> <li>Access n8n at <code>http://localhost:5678</code></li> <li>Log in with your admin credentials</li> <li>Create your first workflow</li> <li>Add nodes for different services</li> <li>Configure connections between nodes</li> <li>Test and activate your workflow</li> </ol>"},{"location":"v1/services/n8n/#common-use-cases","title":"Common Use Cases","text":""},{"location":"v1/services/n8n/#documentation-automation","title":"Documentation Automation","text":"<ul> <li>Auto-generate documentation from code comments</li> <li>Sync documentation between different platforms</li> <li>Notify team when documentation is updated</li> </ul>"},{"location":"v1/services/n8n/#email-campaign-integration","title":"Email Campaign Integration","text":"<ul> <li>Connect Listmonk with external data sources</li> <li>Automate subscriber management</li> <li>Trigger campaigns based on events</li> </ul>"},{"location":"v1/services/n8n/#database-management-with-nocodb","title":"Database Management with NocoDB","text":"<ul> <li>Sync data between NocoDB and external APIs</li> <li>Automate data entry and validation</li> <li>Create backup workflows for database content</li> <li>Generate reports from NocoDB data</li> </ul>"},{"location":"v1/services/n8n/#development-workflows","title":"Development Workflows","text":"<ul> <li>Auto-deploy documentation on git push</li> <li>Sync code changes with documentation</li> <li>Backup automation</li> </ul>"},{"location":"v1/services/n8n/#data-processing","title":"Data Processing","text":"<ul> <li>Process CSV files and import to databases</li> <li>Transform data between different formats</li> <li>Schedule regular data updates</li> </ul>"},{"location":"v1/services/n8n/#example-workflows","title":"Example Workflows","text":""},{"location":"v1/services/n8n/#simple-webhook-to-email","title":"Simple Webhook to Email","text":"<pre><code>Webhook \u2192 Email\n</code></pre>"},{"location":"v1/services/n8n/#scheduled-documentation-backup","title":"Scheduled Documentation Backup","text":"<pre><code>Schedule \u2192 Read Files \u2192 Compress \u2192 Upload to Storage\n</code></pre>"},{"location":"v1/services/n8n/#git-integration","title":"Git Integration","text":"<pre><code>Git Webhook \u2192 Process Changes \u2192 Update Documentation \u2192 Notify Team\n</code></pre>"},{"location":"v1/services/n8n/#security-considerations","title":"Security Considerations","text":"<ul> <li>Use strong encryption keys</li> <li>Secure webhook URLs</li> <li>Regularly update credentials</li> <li>Monitor workflow executions</li> <li>Implement proper error handling</li> </ul>"},{"location":"v1/services/n8n/#integration-with-other-services","title":"Integration with Other Services","text":"<p>n8n can integrate with all services in your Changemaker Lite setup:</p> <ul> <li>Listmonk: Manage subscribers and campaigns</li> <li>PostgreSQL: Read/write database operations</li> <li>Code Server: File operations and git integration</li> <li>MkDocs: Documentation generation and updates</li> </ul>"},{"location":"v1/services/n8n/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v1/services/n8n/#common-issues","title":"Common Issues","text":"<ul> <li>Workflow Execution Errors: Check node configurations and credentials</li> <li>Webhook Issues: Verify URLs and authentication</li> <li>Connection Problems: Check network connectivity between services</li> </ul>"},{"location":"v1/services/n8n/#debugging","title":"Debugging","text":"<pre><code># 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</code></pre>"},{"location":"v1/services/n8n/#official-documentation","title":"Official Documentation","text":"<p>For comprehensive n8n documentation:</p> <ul> <li>n8n Documentation</li> <li>Community Workflows</li> <li>Node Reference</li> <li>GitHub Repository</li> </ul>"},{"location":"v1/services/nocodb/","title":"NocoDB","text":"<p>No-code database platform that turns any database into a smart spreadsheet.</p>"},{"location":"v1/services/nocodb/#overview","title":"Overview","text":"<p>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.</p>"},{"location":"v1/services/nocodb/#features","title":"Features","text":"<ul> <li>Smart Spreadsheet Interface: Transform databases into intuitive spreadsheets</li> <li>Form Builder: Create custom forms for data entry</li> <li>API Generation: Auto-generated REST APIs for all tables</li> <li>Collaboration: Real-time collaboration with team members</li> <li>Access Control: Role-based permissions and sharing</li> <li>Data Visualization: Charts and dashboard creation</li> <li>Webhooks: Integration with external services</li> <li>Import/Export: Support for CSV, Excel, and other formats</li> <li>Multi-Database Support: Works with PostgreSQL, MySQL, SQLite, and more</li> </ul>"},{"location":"v1/services/nocodb/#access","title":"Access","text":"<ul> <li>Default Port: 8090</li> <li>URL: <code>http://localhost:8090</code></li> <li>Database: PostgreSQL (dedicated <code>root_db</code> instance)</li> </ul>"},{"location":"v1/services/nocodb/#configuration","title":"Configuration","text":""},{"location":"v1/services/nocodb/#environment-variables","title":"Environment Variables","text":"<ul> <li><code>NOCODB_PORT</code>: External port mapping (default: 8090)</li> <li><code>NC_DB</code>: Database connection string for PostgreSQL backend</li> </ul>"},{"location":"v1/services/nocodb/#database-backend","title":"Database Backend","text":"<p>NocoDB uses a dedicated PostgreSQL instance (<code>root_db</code>) with the following configuration:</p> <ul> <li>Database Name: <code>root_db</code></li> <li>Username: <code>postgres</code></li> <li>Password: <code>password</code></li> <li>Host: <code>root_db</code> (internal container name)</li> </ul>"},{"location":"v1/services/nocodb/#volumes","title":"Volumes","text":"<ul> <li><code>nc_data</code>: Application data and configuration storage</li> <li><code>db_data</code>: PostgreSQL database files</li> </ul>"},{"location":"v1/services/nocodb/#getting-started","title":"Getting Started","text":"<ol> <li>Access NocoDB: Navigate to <code>http://localhost:8090</code></li> <li>Initial Setup: Complete the onboarding process</li> <li>Create Project: Start with a new project or connect existing databases</li> <li>Add Tables: Import data or create new tables</li> <li>Configure Views: Set up different views (Grid, Form, Gallery, etc.)</li> <li>Set Permissions: Configure user access and sharing settings</li> </ol>"},{"location":"v1/services/nocodb/#common-use-cases","title":"Common Use Cases","text":""},{"location":"v1/services/nocodb/#content-management","title":"Content Management","text":"<ul> <li>Create content databases for blogs and websites</li> <li>Manage product catalogs and inventories</li> <li>Track customer information and interactions</li> </ul>"},{"location":"v1/services/nocodb/#project-management","title":"Project Management","text":"<ul> <li>Task and project tracking systems</li> <li>Team collaboration workspaces</li> <li>Resource and timeline management</li> </ul>"},{"location":"v1/services/nocodb/#data-collection","title":"Data Collection","text":"<ul> <li>Custom forms for surveys and feedback</li> <li>Event registration and management</li> <li>Lead capture and CRM systems</li> </ul>"},{"location":"v1/services/nocodb/#integration-with-other-services","title":"Integration with Other Services","text":"<p>NocoDB can integrate well with other Changemaker Lite services:</p> <ul> <li>n8n Integration: Use NocoDB as a data source/destination in automation workflows</li> <li>Listmonk Integration: Manage subscriber lists and campaign data</li> <li>Documentation: Store and manage documentation metadata</li> </ul>"},{"location":"v1/services/nocodb/#api-usage","title":"API Usage","text":"<p>NocoDB automatically generates REST APIs for all your tables:</p> <pre><code># 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</code></pre>"},{"location":"v1/services/nocodb/#backup-and-data-management","title":"Backup and Data Management","text":""},{"location":"v1/services/nocodb/#database-backup","title":"Database Backup","text":"<p>Since NocoDB uses PostgreSQL, you can backup the database:</p> <pre><code># 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</code></pre>"},{"location":"v1/services/nocodb/#application-data","title":"Application Data","text":"<p>Application settings and metadata are stored in the <code>nc_data</code> volume.</p>"},{"location":"v1/services/nocodb/#security-considerations","title":"Security Considerations","text":"<ul> <li>Change default database credentials in production</li> <li>Configure proper access controls within NocoDB</li> <li>Use HTTPS for production deployments</li> <li>Regularly backup both database and application data</li> <li>Monitor access logs and user activities</li> </ul>"},{"location":"v1/services/nocodb/#performance-tips","title":"Performance Tips","text":"<ul> <li>Regular database maintenance and optimization</li> <li>Monitor memory usage for large datasets</li> <li>Use appropriate indexing for frequently queried fields</li> <li>Consider database connection pooling for high-traffic scenarios</li> </ul>"},{"location":"v1/services/nocodb/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v1/services/nocodb/#common-issues","title":"Common Issues","text":"<p>Service won't start: Check if the PostgreSQL database is healthy</p> <pre><code>docker logs root_db\n</code></pre> <p>Database connection errors: Verify database credentials and network connectivity</p> <pre><code>docker exec nocodb nc_data nc\n</code></pre> <p>Performance issues: Monitor resource usage and optimize queries</p> <pre><code>docker stats nocodb root_db\n</code></pre>"},{"location":"v1/services/nocodb/#official-documentation","title":"Official Documentation","text":"<p>For comprehensive guides and advanced features:</p> <ul> <li>NocoDB Documentation</li> <li>GitHub Repository</li> <li>Community Forum</li> </ul>"},{"location":"v1/services/postgresql/","title":"PostgreSQL Database","text":"<p>Reliable database backend for applications.</p>"},{"location":"v1/services/postgresql/#overview","title":"Overview","text":"<p>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.</p>"},{"location":"v1/services/postgresql/#features","title":"Features","text":"<ul> <li>ACID compliance</li> <li>Advanced SQL features</li> <li>JSON/JSONB support</li> <li>Full-text search</li> <li>Extensibility</li> <li>High performance</li> <li>Reliability and data integrity</li> </ul>"},{"location":"v1/services/postgresql/#access","title":"Access","text":"<ul> <li>Default Port: 5432</li> <li>Host: <code>listmonk-db</code> (internal container name)</li> <li>Database: Set via <code>POSTGRES_DB</code> environment variable</li> <li>Username: Set via <code>POSTGRES_USER</code> environment variable</li> <li>Password: Set via <code>POSTGRES_PASSWORD</code> environment variable</li> </ul>"},{"location":"v1/services/postgresql/#configuration","title":"Configuration","text":""},{"location":"v1/services/postgresql/#environment-variables","title":"Environment Variables","text":"<ul> <li><code>POSTGRES_USER</code>: Database username</li> <li><code>POSTGRES_PASSWORD</code>: Database password </li> <li><code>POSTGRES_DB</code>: Database name</li> </ul>"},{"location":"v1/services/postgresql/#health-checks","title":"Health Checks","text":"<p>The PostgreSQL container includes health checks to ensure the database is ready before dependent services start.</p>"},{"location":"v1/services/postgresql/#data-persistence","title":"Data Persistence","text":"<p>Database data is stored in a Docker volume (<code>listmonk-data</code>) to ensure persistence across container restarts.</p>"},{"location":"v1/services/postgresql/#connecting-to-the-database","title":"Connecting to the Database","text":""},{"location":"v1/services/postgresql/#from-host-machine","title":"From Host Machine","text":"<p>You can connect to PostgreSQL from your host machine using:</p> <pre><code>psql -h localhost -p 5432 -U [username] -d [database]\n</code></pre>"},{"location":"v1/services/postgresql/#from-other-containers","title":"From Other Containers","text":"<p>Other containers can connect using the internal hostname <code>listmonk-db</code> on port 5432.</p>"},{"location":"v1/services/postgresql/#backup-and-restore","title":"Backup and Restore","text":""},{"location":"v1/services/postgresql/#backup","title":"Backup","text":"<pre><code>docker exec listmonk-db pg_dump -U [username] [database] > backup.sql\n</code></pre>"},{"location":"v1/services/postgresql/#restore","title":"Restore","text":"<pre><code>docker exec -i listmonk-db psql -U [username] [database] < backup.sql\n</code></pre>"},{"location":"v1/services/postgresql/#monitoring","title":"Monitoring","text":"<p>Monitor database health and performance through: - Container logs: <code>docker logs listmonk-db</code> - Database metrics and queries - Connection monitoring</p>"},{"location":"v1/services/postgresql/#security-considerations","title":"Security Considerations","text":"<ul> <li>Use strong passwords</li> <li>Regularly update PostgreSQL version</li> <li>Monitor access logs</li> <li>Implement regular backups</li> <li>Consider network isolation</li> </ul>"},{"location":"v1/services/postgresql/#official-documentation","title":"Official Documentation","text":"<p>For comprehensive PostgreSQL documentation: - PostgreSQL Documentation - Docker PostgreSQL Image</p>"},{"location":"v1/services/static-server/","title":"Static Site Server","text":"<p>Nginx-powered static site server for hosting built documentation and websites.</p>"},{"location":"v1/services/static-server/#overview","title":"Overview","text":"<p>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.</p>"},{"location":"v1/services/static-server/#features","title":"Features","text":"<ul> <li>High-performance static file serving</li> <li>Automatic index file handling</li> <li>Gzip compression</li> <li>Caching headers</li> <li>Security headers</li> <li>Custom error pages</li> <li>URL rewriting support</li> </ul>"},{"location":"v1/services/static-server/#access","title":"Access","text":"<ul> <li>Default Port: 4001</li> <li>URL: <code>http://localhost:4001</code></li> <li>Document Root: <code>/config/www</code> (mounted from <code>./mkdocs/site</code>)</li> </ul>"},{"location":"v1/services/static-server/#configuration","title":"Configuration","text":""},{"location":"v1/services/static-server/#environment-variables","title":"Environment Variables","text":"<ul> <li><code>PUID</code>: User ID for file permissions (default: 1000)</li> <li><code>PGID</code>: Group ID for file permissions (default: 1000)</li> <li><code>TZ</code>: Timezone setting (default: Etc/UTC)</li> </ul>"},{"location":"v1/services/static-server/#volumes","title":"Volumes","text":"<ul> <li><code>./mkdocs/site:/config/www</code>: Static site files</li> <li>Built MkDocs site is automatically served</li> </ul>"},{"location":"v1/services/static-server/#usage","title":"Usage","text":"<ol> <li>Build your MkDocs site: <code>docker exec mkdocs-changemaker mkdocs build</code></li> <li>The built site is automatically available at <code>http://localhost:4001</code></li> <li>Any files in <code>./mkdocs/site/</code> will be served statically</li> </ol>"},{"location":"v1/services/static-server/#file-structure","title":"File Structure","text":"<pre><code>mkdocs/site/ # Served at /\n\u251c\u2500\u2500 index.html # Homepage\n\u251c\u2500\u2500 assets/ # CSS, JS, images\n\u251c\u2500\u2500 services/ # Service documentation\n\u2514\u2500\u2500 search/ # Search functionality\n</code></pre>"},{"location":"v1/services/static-server/#performance-features","title":"Performance Features","text":"<ul> <li>Gzip Compression: Automatic compression for text files</li> <li>Browser Caching: Optimized cache headers</li> <li>Fast Static Serving: Nginx optimized for static content</li> <li>Security Headers: Basic security header configuration</li> </ul>"},{"location":"v1/services/static-server/#custom-configuration","title":"Custom Configuration","text":"<p>For advanced Nginx configuration, you can: 1. Create custom Nginx config files 2. Mount them as volumes 3. Restart the container</p>"},{"location":"v1/services/static-server/#monitoring","title":"Monitoring","text":"<p>Monitor the static site server through: - Container logs: <code>docker logs mkdocs-site-server-changemaker</code> - Access logs for traffic analysis - Performance metrics</p>"},{"location":"v1/services/static-server/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v1/services/static-server/#common-issues","title":"Common Issues","text":"<ul> <li>404 Errors: Ensure MkDocs site is built and files exist in <code>./mkdocs/site/</code></li> <li>Permission Issues: Check <code>PUID</code> and <code>PGID</code> settings</li> <li>File Not Found: Verify file paths and case sensitivity</li> </ul>"},{"location":"v1/services/static-server/#debugging","title":"Debugging","text":"<pre><code># 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</code></pre>"},{"location":"v1/services/static-server/#official-documentation","title":"Official Documentation","text":"<p>For more information about the underlying Nginx server: - LinuxServer.io Nginx - Nginx Documentation</p>"},{"location":"v2/","title":"Changemaker Lite V2 Documentation","text":"<p>V2 is Production Ready</p> <p>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.</p>"},{"location":"v2/#overview","title":"Overview","text":"<p>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.</p>"},{"location":"v2/#key-highlights","title":"Key Highlights","text":"<ul> <li>Dual API Architecture: Express.js (main features) + Fastify (media library)</li> <li>Modern Stack: TypeScript, Prisma + Drizzle ORM, PostgreSQL 16, Redis</li> <li>React Admin: Vite + Ant Design + Zustand state management</li> <li>JWT Authentication: Secure role-based access control with refresh tokens</li> <li>Comprehensive Features: 14 backend modules, 42 frontend pages, 8 critical services</li> <li>Production Monitoring: Prometheus, Grafana, Alertmanager with 12 custom metrics</li> <li>Security Audited: 13 findings addressed (Feb 2026)</li> </ul>"},{"location":"v2/#architecture-diagram","title":"Architecture Diagram","text":"<pre><code>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</code></pre>"},{"location":"v2/#feature-modules","title":"Feature Modules","text":""},{"location":"v2/#influence-module","title":"Influence Module","text":"<p>Email advocacy campaigns targeting elected representatives with:</p> <ul> <li>Campaign management with rich text editor</li> <li>Canadian representative lookup (postal code \u2192 MP/MPP/councillor)</li> <li>Public campaign pages with email submission</li> <li>Response wall with upvoting and moderation</li> <li>BullMQ async email queue with SMTP delivery</li> <li>Email verification and tracking</li> </ul> <p>Learn more \u2192</p>"},{"location":"v2/#map-module","title":"Map Module","text":"<p>Geographic mapping and volunteer canvassing with:</p> <ul> <li>Location CRUD with multi-provider geocoding (6 providers)</li> <li>Cut (polygon) management with spatial queries</li> <li>Volunteer shift scheduling and signup system</li> <li>Full canvassing system with GPS tracking and visit recording</li> <li>Walk sheets and QR codes for printable forms</li> <li>NAR 2025 data import (Canadian electoral data)</li> </ul> <p>Learn more \u2192</p>"},{"location":"v2/#landing-pages","title":"Landing Pages","text":"<p>GrapesJS-based page builder with:</p> <ul> <li>WYSIWYG editor with custom blocks</li> <li>Public rendering at <code>/p/:slug</code></li> <li>MkDocs export for static documentation</li> <li>Mobile-responsive templates</li> </ul> <p>Learn more \u2192</p>"},{"location":"v2/#email-templates","title":"Email Templates","text":"<p>Template management system with:</p> <ul> <li>GrapesJS email editor</li> <li>Variable substitution</li> <li>Integration with campaign emails</li> <li>Version control</li> </ul> <p>Learn more \u2192</p>"},{"location":"v2/#media-manager","title":"Media Manager","text":"<p>Video library management with:</p> <ul> <li>Dual API architecture (Fastify microservice)</li> <li>Shared media public gallery</li> <li>Reaction system (6 standard emojis)</li> <li>Job queue monitoring</li> <li>Bulk operations</li> </ul> <p>Learn more \u2192</p>"},{"location":"v2/#newsletter-integration","title":"Newsletter Integration","text":"<p>Listmonk sync with:</p> <ul> <li>Participant/location/user syncing</li> <li>Subscriber list management</li> <li>Health monitoring</li> <li>API integration</li> </ul> <p>Learn more \u2192</p>"},{"location":"v2/#observability","title":"Observability","text":"<p>Comprehensive monitoring with:</p> <ul> <li>Prometheus metrics (12 custom metrics)</li> <li>Grafana dashboards (3 pre-configured)</li> <li>Alertmanager notifications</li> <li>Service health checks</li> <li>Data quality dashboard</li> </ul> <p>Learn more \u2192</p>"},{"location":"v2/#quick-links","title":"Quick Links","text":""},{"location":"v2/#getting-started","title":"Getting Started","text":"<ul> <li>Quick Start Guide - Get running in 5 minutes</li> <li>Installation - Detailed setup instructions</li> <li>Environment Setup - Configure your .env file</li> <li>First Login - Access the admin interface</li> </ul>"},{"location":"v2/#architecture","title":"Architecture","text":"<ul> <li>Architecture Overview - System design and components</li> <li>Dual API Design - Express + Fastify architecture</li> <li>Database Schema - Prisma + Drizzle models</li> <li>Authentication Flow - JWT security model</li> <li>Frontend Architecture - React + Vite + Ant Design</li> </ul>"},{"location":"v2/#development","title":"Development","text":"<ul> <li>Local Development - Set up your dev environment</li> <li>npm Commands - Common development tasks</li> <li>Database Migrations - Schema changes workflow</li> <li>Testing - Test strategy and execution</li> <li>Code Style - Standards and patterns</li> </ul>"},{"location":"v2/#deployment","title":"Deployment","text":"<ul> <li>Docker Compose - Service orchestration</li> <li>Nginx Configuration - Reverse proxy setup</li> <li>Environment Variables - Complete reference</li> <li>Monitoring Stack - Prometheus + Grafana</li> <li>Backup & Restore - Data protection</li> </ul>"},{"location":"v2/#api-reference","title":"API Reference","text":"<ul> <li>Authentication API - Login, register, refresh</li> <li>Campaigns API - Campaign CRUD</li> <li>Locations API - Location management</li> <li>Media API - Video library</li> <li>Complete API Index - All endpoints</li> </ul>"},{"location":"v2/#user-guides","title":"User Guides","text":"<ul> <li>Admin Guide - Platform administration</li> <li>Volunteer Guide - Canvassing workflows</li> <li>Campaign Manager Guide - Running campaigns</li> <li>Map Organizer Guide - Location management</li> <li>Content Editor Guide - Landing pages</li> </ul>"},{"location":"v2/#technology-stack","title":"Technology Stack","text":""},{"location":"v2/#backend","title":"Backend","text":"<ul> <li>Express.js - Main API server (TypeScript, port 4000)</li> <li>Fastify - Media API microservice (TypeScript, port 4100)</li> <li>Prisma ORM - Database modeling and migrations (27+ models)</li> <li>Drizzle ORM - Media tables (lightweight schema-first)</li> <li>PostgreSQL 16 - Primary database</li> <li>Redis - Caching, sessions, rate limiting, BullMQ backend</li> <li>BullMQ - Job queues (email sending, geocoding)</li> <li>Winston - Structured logging</li> </ul>"},{"location":"v2/#frontend","title":"Frontend","text":"<ul> <li>React 19 - UI library</li> <li>Vite - Build tool and dev server</li> <li>Ant Design 5 - Component library</li> <li>Zustand - State management</li> <li>React Router - Client-side routing</li> <li>Axios - HTTP client with interceptors</li> <li>Leaflet - Interactive maps</li> <li>GrapesJS - WYSIWYG page builder</li> </ul>"},{"location":"v2/#infrastructure","title":"Infrastructure","text":"<ul> <li>Docker Compose - Service orchestration (20+ containers)</li> <li>Nginx - Reverse proxy with subdomain routing</li> <li>Prometheus - Metrics collection</li> <li>Grafana - Metrics visualization</li> <li>Alertmanager - Alert routing</li> <li>Listmonk - Newsletter platform</li> <li>MailHog - Email testing (development)</li> </ul>"},{"location":"v2/#project-status","title":"Project Status","text":""},{"location":"v2/#completed-phases-1-14","title":"Completed Phases (1-14)","text":"<p>\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</p>"},{"location":"v2/#additional-features","title":"Additional Features","text":"<p>\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</p>"},{"location":"v2/#current-phase","title":"Current Phase","text":"<p>\ud83d\udea7 Phase 15: Testing + Polish - Comprehensive testing, documentation</p>"},{"location":"v2/#migration-from-v1","title":"Migration from V1","text":"<p>If you're migrating from Changemaker Lite V1 (NocoDB-based architecture), see the Migration Guide for:</p> <ul> <li>Breaking changes (NocoDB \u2192 Prisma, sessions \u2192 JWT)</li> <li>Data migration strategy</li> <li>API endpoint mapping</li> <li>Feature parity comparison</li> </ul>"},{"location":"v2/#contributing","title":"Contributing","text":"<p>Changemaker Lite is open source. We welcome contributions! See the Contributing Guide for:</p> <ul> <li>Development setup</li> <li>Code standards</li> <li>Pull request process</li> <li>Roadmap (Phase 15+)</li> </ul>"},{"location":"v2/#support","title":"Support","text":"<ul> <li>Documentation Issues: Report on GitHub</li> <li>Security Issues: See Security Policy</li> <li>General Questions: Check Troubleshooting and FAQ</li> </ul> <p>Last Updated: February 2026 | Version: 2.0.0 | Status: Production Ready</p>"},{"location":"v2/api-reference/","title":"API Reference","text":"<p>Complete REST API reference for Changemaker Lite V2. This section documents all API endpoints, request/response formats, authentication, and error handling.</p>"},{"location":"v2/api-reference/#overview","title":"Overview","text":"<p>Changemaker Lite V2 provides two REST APIs:</p> <ul> <li>Express API (Port 4000) - Main application API</li> <li>Fastify Media API (Port 4100) - Media library operations</li> </ul> <p>Both APIs use JSON for request/response bodies and follow RESTful conventions.</p>"},{"location":"v2/api-reference/#api-documentation","title":"API Documentation","text":"<p>API reference documentation will be added as the API stabilizes. Planned documentation includes:</p>"},{"location":"v2/api-reference/#authentication-endpoints","title":"Authentication Endpoints","text":"<ul> <li><code>POST /api/auth/register</code> - User registration</li> <li><code>POST /api/auth/login</code> - User login</li> <li><code>POST /api/auth/refresh</code> - Refresh access token</li> <li><code>POST /api/auth/logout</code> - User logout</li> <li><code>GET /api/auth/me</code> - Get current user</li> </ul>"},{"location":"v2/api-reference/#user-endpoints","title":"User Endpoints","text":"<ul> <li><code>GET /api/users</code> - List users</li> <li><code>POST /api/users</code> - Create user</li> <li><code>GET /api/users/:id</code> - Get user</li> <li><code>PATCH /api/users/:id</code> - Update user</li> <li><code>DELETE /api/users/:id</code> - Delete user</li> </ul>"},{"location":"v2/api-reference/#campaign-endpoints","title":"Campaign Endpoints","text":"<ul> <li><code>GET /api/campaigns</code> - List campaigns</li> <li><code>POST /api/campaigns</code> - Create campaign</li> <li><code>GET /api/campaigns/:id</code> - Get campaign</li> <li><code>PATCH /api/campaigns/:id</code> - Update campaign</li> <li><code>DELETE /api/campaigns/:id</code> - Delete campaign</li> <li><code>GET /api/campaigns/public</code> - List public campaigns</li> <li><code>POST /api/campaigns/:id/send-email</code> - Send campaign email</li> </ul>"},{"location":"v2/api-reference/#location-endpoints","title":"Location Endpoints","text":"<ul> <li><code>GET /api/locations</code> - List locations</li> <li><code>POST /api/locations</code> - Create location</li> <li><code>GET /api/locations/:id</code> - Get location</li> <li><code>PATCH /api/locations/:id</code> - Update location</li> <li><code>DELETE /api/locations/:id</code> - Delete location</li> <li><code>POST /api/locations/import</code> - CSV import</li> <li><code>GET /api/locations/export</code> - CSV export</li> <li><code>POST /api/locations/geocode</code> - Bulk geocode</li> </ul>"},{"location":"v2/api-reference/#map-endpoints","title":"Map Endpoints","text":"<ul> <li><code>GET /api/cuts</code> - List cuts</li> <li><code>POST /api/cuts</code> - Create cut</li> <li><code>GET /api/shifts</code> - List shifts</li> <li><code>POST /api/shifts</code> - Create shift</li> <li><code>GET /api/canvass/session</code> - Get active session</li> <li><code>POST /api/canvass/session/start</code> - Start session</li> <li><code>POST /api/canvass/visit</code> - Record visit</li> </ul>"},{"location":"v2/api-reference/#content-endpoints","title":"Content Endpoints","text":"<ul> <li><code>GET /api/pages</code> - List pages</li> <li><code>POST /api/pages</code> - Create page</li> <li><code>GET /api/pages/public/:slug</code> - Get published page</li> <li><code>GET /api/email-templates</code> - List templates</li> <li><code>POST /api/email-templates</code> - Create template</li> </ul>"},{"location":"v2/api-reference/#media-endpoints-port-4100","title":"Media Endpoints (Port 4100)","text":"<ul> <li><code>GET /media-api/videos</code> - List videos</li> <li><code>POST /media-api/upload</code> - Upload video</li> <li><code>GET /media-api/public/videos</code> - List public videos</li> <li><code>POST /media-api/reactions</code> - Add reaction</li> </ul>"},{"location":"v2/api-reference/#authentication","title":"Authentication","text":"<p>All authenticated endpoints require a valid JWT access token in the Authorization header:</p> <pre><code>Authorization: Bearer <access_token>\n</code></pre>"},{"location":"v2/api-reference/#token-lifecycle","title":"Token Lifecycle","text":"<ol> <li>Login - POST <code>/api/auth/login</code></li> <li> <p>Returns: <code>accessToken</code> (15min) + <code>refreshToken</code> (7 days)</p> </li> <li> <p>Access Protected Resource - Include token in header</p> </li> <li> <p>Token verified by <code>authenticate</code> middleware</p> </li> <li> <p>Refresh Token - POST <code>/api/auth/refresh</code></p> </li> <li>Provide: <code>refreshToken</code></li> <li> <p>Returns: New <code>accessToken</code> + <code>refreshToken</code></p> </li> <li> <p>Logout - POST <code>/api/auth/logout</code></p> </li> <li>Invalidates refresh token</li> </ol>"},{"location":"v2/api-reference/#role-based-access","title":"Role-Based Access","text":"<p>Endpoints are protected by role requirements:</p> <ul> <li>Public - No authentication required</li> <li>Authenticated - Any logged-in user</li> <li>Admin - SUPER_ADMIN, INFLUENCE_ADMIN, or MAP_ADMIN</li> <li>Role-Specific - Specific role required</li> </ul>"},{"location":"v2/api-reference/#request-format","title":"Request Format","text":""},{"location":"v2/api-reference/#json-body","title":"JSON Body","text":"<pre><code>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</code></pre>"},{"location":"v2/api-reference/#query-parameters","title":"Query Parameters","text":"<pre><code>GET /api/campaigns?page=1&limit=20&search=parks\n</code></pre>"},{"location":"v2/api-reference/#path-parameters","title":"Path Parameters","text":"<pre><code>GET /api/campaigns/:id\n</code></pre>"},{"location":"v2/api-reference/#response-format","title":"Response Format","text":""},{"location":"v2/api-reference/#success-response","title":"Success Response","text":"<pre><code>{\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</code></pre>"},{"location":"v2/api-reference/#paginated-response","title":"Paginated Response","text":"<pre><code>{\n \"data\": [...],\n \"pagination\": {\n \"page\": 1,\n \"limit\": 20,\n \"total\": 100,\n \"totalPages\": 5\n }\n}\n</code></pre>"},{"location":"v2/api-reference/#error-response","title":"Error Response","text":"<pre><code>{\n \"error\": \"Validation error\",\n \"details\": \"Invalid email format\",\n \"statusCode\": 400\n}\n</code></pre>"},{"location":"v2/api-reference/#status-codes","title":"Status Codes","text":"<ul> <li>200 OK - Success</li> <li>201 Created - Resource created</li> <li>204 No Content - Success with no body</li> <li>400 Bad Request - Validation error</li> <li>401 Unauthorized - Authentication required</li> <li>403 Forbidden - Insufficient permissions</li> <li>404 Not Found - Resource not found</li> <li>429 Too Many Requests - Rate limit exceeded</li> <li>500 Internal Server Error - Server error</li> </ul>"},{"location":"v2/api-reference/#rate-limiting","title":"Rate Limiting","text":"<p>Rate limits vary by endpoint:</p> <ul> <li>Auth endpoints - 10 requests/minute per IP</li> <li>Canvass visits - 30 requests/minute per IP</li> <li>Public endpoints - 60 requests/minute per IP</li> <li>Authenticated endpoints - 120 requests/minute per user</li> </ul> <p>Rate limit headers:</p> <pre><code>X-RateLimit-Limit: 60\nX-RateLimit-Remaining: 59\nX-RateLimit-Reset: 1640995200\n</code></pre>"},{"location":"v2/api-reference/#cors","title":"CORS","text":"<p>CORS is enabled for all origins in development:</p> <pre><code>app.use(cors({\n origin: '*',\n credentials: true,\n}));\n</code></pre> <p>Production should restrict to known domains.</p>"},{"location":"v2/api-reference/#validation","title":"Validation","text":"<p>Request bodies are validated using Zod schemas. Validation errors return 400 with details:</p> <pre><code>{\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</code></pre>"},{"location":"v2/api-reference/#pagination","title":"Pagination","text":"<p>List endpoints support pagination:</p> <ul> <li>page - Page number (default: 1)</li> <li>limit - Items per page (default: 20, max: 100)</li> </ul> <p>Example: <pre><code>GET /api/campaigns?page=2&limit=50\n</code></pre></p>"},{"location":"v2/api-reference/#search-filtering","title":"Search & Filtering","text":"<p>List endpoints support search and filtering:</p> <ul> <li>search - Text search (varies by endpoint)</li> <li>filter - Field-specific filters</li> </ul> <p>Example: <pre><code>GET /api/campaigns?search=parks&published=true\n</code></pre></p>"},{"location":"v2/api-reference/#sorting","title":"Sorting","text":"<p>List endpoints support sorting:</p> <ul> <li>sort - Field to sort by</li> <li>order - Sort direction (asc/desc)</li> </ul> <p>Example: <pre><code>GET /api/campaigns?sort=createdAt&order=desc\n</code></pre></p>"},{"location":"v2/api-reference/#api-endpoints-by-module","title":"API Endpoints by Module","text":""},{"location":"v2/api-reference/#authentication_1","title":"Authentication","text":"<ul> <li>Login, register, refresh, logout, current user</li> </ul>"},{"location":"v2/api-reference/#users","title":"Users","text":"<ul> <li>CRUD operations, pagination, search, role management</li> </ul>"},{"location":"v2/api-reference/#settings","title":"Settings","text":"<ul> <li>Site settings singleton</li> </ul>"},{"location":"v2/api-reference/#campaigns","title":"Campaigns","text":"<ul> <li>CRUD, public listing, email sending</li> </ul>"},{"location":"v2/api-reference/#representatives","title":"Representatives","text":"<ul> <li>Postal code lookup, cache management</li> </ul>"},{"location":"v2/api-reference/#responses","title":"Responses","text":"<ul> <li>CRUD, verification, upvoting, moderation</li> </ul>"},{"location":"v2/api-reference/#postal-codes","title":"Postal Codes","text":"<ul> <li>Cache service</li> </ul>"},{"location":"v2/api-reference/#campaign-emails","title":"Campaign Emails","text":"<ul> <li>Email tracking, statistics</li> </ul>"},{"location":"v2/api-reference/#email-queue","title":"Email Queue","text":"<ul> <li>Queue monitoring, pause/resume, cleanup</li> </ul>"},{"location":"v2/api-reference/#locations","title":"Locations","text":"<ul> <li>CRUD, CSV import/export, geocoding, NAR import</li> </ul>"},{"location":"v2/api-reference/#cuts","title":"Cuts","text":"<ul> <li>CRUD, spatial queries, location assignment</li> </ul>"},{"location":"v2/api-reference/#shifts","title":"Shifts","text":"<ul> <li>CRUD, signups, email notifications</li> </ul>"},{"location":"v2/api-reference/#canvass","title":"Canvass","text":"<ul> <li>Sessions, visits, routes, dashboard</li> </ul>"},{"location":"v2/api-reference/#tracking","title":"Tracking","text":"<ul> <li>GPS tracking (future)</li> </ul>"},{"location":"v2/api-reference/#map-settings","title":"Map Settings","text":"<ul> <li>Map configuration</li> </ul>"},{"location":"v2/api-reference/#pages","title":"Pages","text":"<ul> <li>CRUD, block library, MkDocs export, public rendering</li> </ul>"},{"location":"v2/api-reference/#email-templates","title":"Email Templates","text":"<ul> <li>CRUD, versioning (future)</li> </ul>"},{"location":"v2/api-reference/#media-port-4100","title":"Media (Port 4100)","text":"<ul> <li>Videos, upload, shared media, reactions, jobs</li> </ul>"},{"location":"v2/api-reference/#listmonk","title":"Listmonk","text":"<ul> <li>Status, sync, test connection</li> </ul>"},{"location":"v2/api-reference/#pangolin","title":"Pangolin","text":"<ul> <li>Tunnel management, setup, configuration</li> </ul>"},{"location":"v2/api-reference/#docs","title":"Docs","text":"<ul> <li>MkDocs/Code Server status</li> </ul>"},{"location":"v2/api-reference/#qr","title":"QR","text":"<ul> <li>QR code generation</li> </ul>"},{"location":"v2/api-reference/#observability","title":"Observability","text":"<ul> <li>Prometheus/Grafana/Alertmanager integration</li> </ul>"},{"location":"v2/api-reference/#services","title":"Services","text":"<ul> <li>Health checks</li> </ul>"},{"location":"v2/api-reference/#openapi-specification","title":"OpenAPI Specification","text":"<p>OpenAPI/Swagger documentation is planned for future releases. This will provide:</p> <ul> <li>Interactive API explorer</li> <li>Auto-generated client libraries</li> <li>Comprehensive endpoint documentation</li> <li>Request/response examples</li> </ul>"},{"location":"v2/api-reference/#testing","title":"Testing","text":"<p>Test API endpoints using:</p> <ul> <li>curl - Command-line HTTP client</li> <li>Postman - GUI API client</li> <li>HTTPie - User-friendly CLI</li> <li>Insomnia - API design/testing tool</li> </ul> <p>Example with curl:</p> <pre><code># 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</code></pre>"},{"location":"v2/api-reference/#related-documentation","title":"Related Documentation","text":"<ul> <li>Backend Modules</li> <li>Authentication</li> <li>Middleware</li> <li>Development Guide</li> <li>Troubleshooting</li> </ul>"},{"location":"v2/architecture/","title":"V2 Architecture Overview","text":"<p>Changemaker Lite V2 is built on a modern microservices architecture with a dual API design, React admin interface, and comprehensive observability.</p>"},{"location":"v2/architecture/#system-architecture","title":"System Architecture","text":"<pre><code>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</code></pre>"},{"location":"v2/architecture/#core-components","title":"Core Components","text":""},{"location":"v2/architecture/#1-nginx-reverse-proxy","title":"1. Nginx Reverse Proxy","text":"<p>Purpose: Routes HTTP requests to appropriate services based on subdomain</p> <p>Subdomains: - <code>app.cmlite.org</code> \u2192 Admin GUI (React) - <code>api.cmlite.org</code> \u2192 Express API (main features) - <code>media.cmlite.org</code> \u2192 Fastify API (video library) - <code>db.cmlite.org</code> \u2192 NocoDB (data browser) - <code>docs.cmlite.org</code> \u2192 MkDocs (documentation) - <code>listmonk.cmlite.org</code> \u2192 Listmonk (newsletter) - <code>grafana.cmlite.org</code> \u2192 Grafana (monitoring) - And 8 more service subdomains...</p> <p>Configuration: <code>/nginx/conf.d/</code></p> <p>Learn more \u2192</p>"},{"location":"v2/architecture/#2-frontend-layer","title":"2. Frontend Layer","text":""},{"location":"v2/architecture/#admin-gui-port-3000","title":"Admin GUI (Port 3000)","text":"<ul> <li>Framework: React 19 with Vite build tool</li> <li>UI Library: Ant Design 5 (Table, Form, Modal, Drawer components)</li> <li>State Management: Zustand stores (auth, canvass)</li> <li>Routing: React Router v6</li> <li>HTTP Client: Axios with 401 refresh interceptor</li> </ul> <p>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)</p> <p>Learn more \u2192</p>"},{"location":"v2/architecture/#public-pages","title":"Public Pages","text":"<ul> <li>Dark blue/teal theme (consistent with V1 branding)</li> <li>No authentication required</li> <li>Mobile-responsive layouts</li> <li>Public campaign submission</li> <li>Response wall with upvoting</li> <li>Public map with location markers</li> <li>Shift signup forms</li> </ul>"},{"location":"v2/architecture/#volunteer-portal","title":"Volunteer Portal","text":"<ul> <li>Top navigation layout</li> <li>Mobile-optimized (hamburger menu)</li> <li>GPS-tracked canvassing</li> <li>Full-screen map interface</li> <li>Visit recording forms</li> <li>Activity tracking</li> </ul>"},{"location":"v2/architecture/#3-backend-layer-dual-api-design","title":"3. Backend Layer - Dual API Design","text":""},{"location":"v2/architecture/#express-api-port-4000","title":"Express API (Port 4000)","text":"<p>Main application server handling core features:</p> <p>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)</p> <p>Plus: email-templates, listmonk, pangolin, docs, qr, services, observability</p> <p>ORM: Prisma (27+ models)</p> <p>Architecture: - Layered structure (routes \u2192 services \u2192 database) - Zod schema validation - Role-based access control (RBAC) - Error handling middleware - Winston logging</p> <p>Learn more \u2192</p>"},{"location":"v2/architecture/#fastify-api-port-4100","title":"Fastify API (Port 4100)","text":"<p>Specialized microservice for media library:</p> <p>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</p> <p>ORM: Drizzle (lightweight schema-first)</p> <p>Why Separate?: - Performance isolation (video ops don't slow main API) - Different ORM evaluation (Drizzle vs Prisma) - Independent scaling - Clear service boundaries</p> <p>Shared Resources: - Same PostgreSQL database (different schemas) - Same Redis instance - Reuses JWT_ACCESS_SECRET for auth</p> <p>Learn more \u2192</p>"},{"location":"v2/architecture/#4-data-layer","title":"4. Data Layer","text":""},{"location":"v2/architecture/#postgresql-16","title":"PostgreSQL 16","text":"<p>Primary database with two ORM schemas:</p> <p>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)</p> <p>Drizzle Schema (media tables): - videos - shared_media - reactions - jobs</p> <p>Indexes: Optimized for common queries (userId, campaignId, cutId, etc.)</p> <p>Learn more \u2192</p>"},{"location":"v2/architecture/#redis","title":"Redis","text":"<p>Multi-purpose cache and queue backend:</p> <ul> <li>Caching: Postal codes (7-day TTL), representatives</li> <li>Rate Limiting: Per-endpoint limits (Redis-backed)</li> <li>BullMQ Queues: Email sending, bulk geocoding</li> <li>Sessions: Future session storage (if needed)</li> </ul> <p>Authentication: Required (<code>REDIS_PASSWORD</code> env var)</p>"},{"location":"v2/architecture/#5-job-processing","title":"5. Job Processing","text":""},{"location":"v2/architecture/#bullmq-queues","title":"BullMQ Queues","text":"<p>Async job processing for long-running operations:</p> <p>Email Queue: - Campaign email sending (SMTP) - Email verification (double opt-in) - Confirmation emails (shift signups) - Retry logic (exponential backoff) - Rate limiting (avoid spam flagging)</p> <p>Geocoding Queue: - Bulk address geocoding - Multi-provider fallback (6 services) - Rate limit compliance (500 jobs/min) - Result caching</p> <p>Queue Management: - Admin routes for pause/resume - Job status monitoring - Failed job inspection - Queue metrics (Prometheus)</p>"},{"location":"v2/architecture/#6-external-services","title":"6. External Services","text":""},{"location":"v2/architecture/#smtp-server","title":"SMTP Server","text":"<p>Email delivery for: - Campaign advocacy emails - Email verification - Password reset - Shift confirmation - Admin notifications</p> <p>Dev Mode: MailHog captures emails (<code>EMAIL_TEST_MODE=true</code>)</p>"},{"location":"v2/architecture/#represent-api","title":"Represent API","text":"<p>Canadian elected representative lookup: - Postal code \u2192 MPs, MPPs, councillors - Caching (7-day TTL per postal code) - Fallback to cached data on API errors</p>"},{"location":"v2/architecture/#geocoding-providers","title":"Geocoding Providers","text":"<p>Multi-provider geocoding with fallback:</p> <ol> <li>Nominatim (OpenStreetMap, free)</li> <li>Mapbox (requires API key, best accuracy)</li> <li>ArcGIS (free tier available)</li> <li>Photon (OSM-based, no key required)</li> <li>Google (requires API key, high cost)</li> <li>LocationIQ (requires API key, generous free tier)</li> </ol> <p>Strategy: Try each provider in order until success</p>"},{"location":"v2/architecture/#listmonk-newsletter-platform","title":"Listmonk Newsletter Platform","text":"<p>Email marketing integration: - Sync participants/locations/users \u2192 subscriber lists - Newsletter campaigns (separate from advocacy emails) - API integration (basic auth) - Health monitoring</p>"},{"location":"v2/architecture/#7-observability-stack","title":"7. Observability Stack","text":""},{"location":"v2/architecture/#prometheus","title":"Prometheus","text":"<p>Metrics collection with custom instrumentation:</p> <p>12 Custom Metrics (<code>cm_*</code> prefix): - <code>cm_api_uptime_seconds</code> - API availability - <code>cm_email_queue_size</code> - Queue depth - <code>cm_email_sent_total</code> - Email delivery count - <code>cm_geocode_success_rate</code> - Geocoding quality - <code>cm_active_canvass_sessions</code> - Live canvassing - And 7 more domain-specific metrics...</p> <p>HTTP Metrics: - <code>http_request_total</code> - Total requests - <code>http_request_duration_seconds</code> - Latency histogram - <code>http_request_errors_total</code> - Error count</p> <p>Scrape Targets: - Express API (<code>:4000/metrics</code>) - Fastify API (<code>:4100/metrics</code>) - Redis Exporter - Node Exporter (host metrics) - cAdvisor (container metrics)</p> <p>Learn more \u2192</p>"},{"location":"v2/architecture/#grafana","title":"Grafana","text":"<p>Visualization dashboards:</p> <ol> <li>Application Overview - API metrics, queue stats, sessions</li> <li>Infrastructure - Container metrics, host resources, Redis</li> <li>Alerts & SLOs - Error budgets, SLI tracking</li> </ol> <p>Auto-provisioned: Dashboards in <code>/configs/grafana/</code></p>"},{"location":"v2/architecture/#alertmanager","title":"Alertmanager","text":"<p>Alert routing and notifications:</p> <p>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...</p> <p>Notification Channels: - Gotify (self-hosted push notifications) - Email (SMTP) - Webhook (custom integrations)</p>"},{"location":"v2/architecture/#request-lifecycle","title":"Request Lifecycle","text":""},{"location":"v2/architecture/#example-public-campaign-email-submission","title":"Example: Public Campaign Email Submission","text":"<pre><code>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</code></pre>"},{"location":"v2/architecture/#technology-decisions","title":"Technology Decisions","text":""},{"location":"v2/architecture/#why-typescript","title":"Why TypeScript?","text":"<ul> <li>Type safety reduces runtime errors</li> <li>Better IDE support and autocomplete</li> <li>Easier refactoring</li> <li>Self-documenting code</li> </ul>"},{"location":"v2/architecture/#why-prisma-drizzle","title":"Why Prisma + Drizzle?","text":"<ul> <li>Prisma: Great for complex models, migrations, auto-generated types</li> <li>Drizzle: Lightweight, perfect for simple media tables</li> <li>Evaluate both ORMs in production</li> </ul>"},{"location":"v2/architecture/#why-dual-api","title":"Why Dual API?","text":"<ul> <li>Separation of concerns: Media ops isolated from core features</li> <li>Performance: Video processing doesn't block main API</li> <li>Scalability: Independent horizontal scaling</li> <li>Technology evaluation: Compare Express vs Fastify</li> </ul>"},{"location":"v2/architecture/#why-jwt-over-sessions","title":"Why JWT over Sessions?","text":"<ul> <li>Stateless (scales horizontally)</li> <li>No session storage overhead</li> <li>Works across multiple API servers</li> <li>Standard claims (iat, exp, sub)</li> </ul>"},{"location":"v2/architecture/#why-bullmq-over-bull","title":"Why BullMQ over Bull?","text":"<ul> <li>Better TypeScript support</li> <li>Improved performance</li> <li>Active maintenance</li> <li>Better documentation</li> </ul>"},{"location":"v2/architecture/#why-postgresql-over-nosql","title":"Why PostgreSQL over NoSQL?","text":"<ul> <li>Complex relational data (campaigns, locations, users)</li> <li>ACID transactions (critical for email queue)</li> <li>Full-text search</li> <li>Spatial queries (PostGIS for future geo features)</li> </ul>"},{"location":"v2/architecture/#deployment-architecture","title":"Deployment Architecture","text":""},{"location":"v2/architecture/#docker-compose","title":"Docker Compose","text":"<p>All services orchestrated in <code>docker-compose.yml</code>:</p> <p>Profiles: - <code>default</code>: Core services (postgres, redis, api, admin, nginx) - <code>monitoring</code>: Prometheus, Grafana, Alertmanager, exporters</p> <p>Networks: - <code>changemaker-lite</code> bridge network - Service discovery via container names</p> <p>Volumes: - PostgreSQL data persistence - Redis data persistence - Uploads directory - Logs directory</p> <p>Learn more \u2192</p>"},{"location":"v2/architecture/#nginx-routing","title":"Nginx Routing","text":"<p>Subdomain-based routing:</p> <pre><code># 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</code></pre> <p>Learn more \u2192</p>"},{"location":"v2/architecture/#security-architecture","title":"Security Architecture","text":""},{"location":"v2/architecture/#authentication-flow","title":"Authentication Flow","text":"<pre><code>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</code></pre> <p>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)</p> <p>Learn more \u2192</p>"},{"location":"v2/architecture/#security-layers","title":"Security Layers","text":"<ol> <li>Network: Nginx rate limiting, fail2ban</li> <li>Application: Input validation (Zod schemas), RBAC</li> <li>Data: Encrypted fields (ENCRYPTION_KEY), SQL injection prevention (Prisma)</li> <li>Transport: HTTPS only (production), HSTS headers</li> </ol> <p>Learn more \u2192</p>"},{"location":"v2/architecture/#scalability-considerations","title":"Scalability Considerations","text":""},{"location":"v2/architecture/#horizontal-scaling","title":"Horizontal Scaling","text":"<ul> <li>Stateless APIs: JWT auth allows multiple API instances</li> <li>Redis-backed queues: Share job queues across workers</li> <li>Database connection pooling: Prisma manages connections</li> <li>Nginx load balancing: Distribute requests across API instances</li> </ul>"},{"location":"v2/architecture/#vertical-scaling","title":"Vertical Scaling","text":"<ul> <li>Increase container resources (CPU, memory)</li> <li>Optimize database queries (indexes, query planning)</li> <li>Redis memory limits (LRU eviction policy)</li> </ul>"},{"location":"v2/architecture/#bottlenecks","title":"Bottlenecks","text":"<ul> <li>PostgreSQL: Single primary (future: read replicas)</li> <li>Redis: Single instance (future: Redis Cluster)</li> <li>File uploads: Local disk (future: S3-compatible storage)</li> </ul>"},{"location":"v2/architecture/#monitoring-observability","title":"Monitoring & Observability","text":""},{"location":"v2/architecture/#golden-signals","title":"Golden Signals","text":"<ol> <li>Latency: Request duration histograms</li> <li>Traffic: Request rate by endpoint</li> <li>Errors: Error rate (5xx responses)</li> <li>Saturation: Database connections, Redis memory, queue depth</li> </ol>"},{"location":"v2/architecture/#slos-service-level-objectives","title":"SLOs (Service Level Objectives)","text":"<ul> <li>Availability: 99.9% uptime (8.76 hours downtime/year)</li> <li>Latency: p95 < 500ms, p99 < 1000ms</li> <li>Error Rate: < 0.1% (1 error per 1000 requests)</li> </ul>"},{"location":"v2/architecture/#alerting-strategy","title":"Alerting Strategy","text":"<ul> <li>Critical: Page on-call (service down, database unavailable)</li> <li>Warning: Create ticket (queue growing, elevated errors)</li> <li>Info: Log only (slow query, cache miss)</li> </ul> <p>Learn more \u2192</p>"},{"location":"v2/architecture/#further-reading","title":"Further Reading","text":"<ul> <li>Dual API Architecture - Express + Fastify design</li> <li>Database Schema - Complete ER diagram</li> <li>Authentication Flow - JWT security model</li> <li>Frontend Architecture - React + Vite + Ant Design</li> <li>Networking - Nginx routing and subdomains</li> <li>Security Model - Comprehensive security audit</li> <li>Monitoring Stack - Prometheus + Grafana + Alertmanager</li> <li>Data Flow - Request lifecycle examples</li> </ul> <p>Next: Set up your development environment \u2192</p>"},{"location":"v2/architecture/authentication/","title":"Authentication Flow","text":"<p>Changemaker Lite V2 uses JWT-based authentication with access and refresh tokens for stateless, scalable authentication.</p>"},{"location":"v2/architecture/authentication/#overview","title":"Overview","text":"<p>Key Features:</p> <ul> <li>JWT Tokens - Stateless authentication (no session storage)</li> <li>Dual Token System - Short-lived access tokens (15min) + long-lived refresh tokens (7 days)</li> <li>Refresh Token Rotation - Atomic transaction prevents race conditions</li> <li>Password Policy - Enforced 12+ characters with complexity requirements</li> <li>Rate Limiting - 10 requests/min on auth endpoints</li> <li>User Enumeration Prevention - Consistent 401 responses</li> <li>RBAC - Role-based access control with 5 roles</li> </ul>"},{"location":"v2/architecture/authentication/#authentication-architecture","title":"Authentication Architecture","text":"<pre><code>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</code></pre>"},{"location":"v2/architecture/authentication/#user-roles","title":"User Roles","text":""},{"location":"v2/architecture/authentication/#role-hierarchy","title":"Role Hierarchy","text":"<pre><code>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</code></pre>"},{"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 <p>TEMP User Behavior: - Created automatically for public shift signups - Auto-expires after configured days (<code>expiresAt</code>, <code>expireDays</code> fields) - Limited to volunteer canvassing features - Cannot access admin pages</p>"},{"location":"v2/architecture/authentication/#login-flow","title":"Login Flow","text":""},{"location":"v2/architecture/authentication/#sequence-diagram","title":"Sequence Diagram","text":"<pre><code>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</code></pre>"},{"location":"v2/architecture/authentication/#implementation","title":"Implementation","text":"<p>File: <code>api/src/modules/auth/auth.service.ts</code> (lines 22-56)</p> <pre><code>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</code></pre>"},{"location":"v2/architecture/authentication/#password-policy","title":"Password Policy","text":"<p>Enforced at Zod schema level:</p> <p>File: <code>api/src/modules/auth/auth.schemas.ts</code> (lines 9-16)</p> <pre><code>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</code></pre> <p>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)</p> <p>Note: Policy is NOT enforced on login (only on registration/password change) to avoid breaking existing accounts.</p>"},{"location":"v2/architecture/authentication/#refresh-token-flow","title":"Refresh Token Flow","text":""},{"location":"v2/architecture/authentication/#sequence-diagram_1","title":"Sequence Diagram","text":"<pre><code>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</code></pre>"},{"location":"v2/architecture/authentication/#implementation_1","title":"Implementation","text":"<p>File: <code>api/src/modules/auth/auth.service.ts</code> (lines 82-130)</p> <pre><code>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</code></pre> <p>Critical: Refresh token rotation happens in a single database transaction to prevent race conditions (e.g., multiple refresh attempts).</p>"},{"location":"v2/architecture/authentication/#frontend-integration","title":"Frontend Integration","text":""},{"location":"v2/architecture/authentication/#zustand-auth-store","title":"Zustand Auth Store","text":"<p>File: <code>admin/src/stores/auth.store.ts</code> (lines 1-100)</p> <pre><code>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</code></pre>"},{"location":"v2/architecture/authentication/#axios-401-interceptor","title":"Axios 401 Interceptor","text":"<p>File: <code>admin/src/lib/api.ts</code> (lines 34-78)</p> <pre><code>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</code></pre> <p>Key Features: - Automatic token refresh on 401 - Deduplicates concurrent refresh requests (callback queue) - Retries original request after refresh - Logs out on refresh failure</p>"},{"location":"v2/architecture/authentication/#middleware","title":"Middleware","text":""},{"location":"v2/architecture/authentication/#jwt-verification","title":"JWT Verification","text":"<p>File: <code>api/src/middleware/auth.ts</code> (lines 1-35)</p> <pre><code>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</code></pre>"},{"location":"v2/architecture/authentication/#role-based-access-control-rbac","title":"Role-Based Access Control (RBAC)","text":"<p>File: <code>api/src/middleware/auth.ts</code> (lines 37-55)</p> <pre><code>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</code></pre> <p>Usage:</p> <pre><code>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</code></pre>"},{"location":"v2/architecture/authentication/#rate-limiting","title":"Rate Limiting","text":"<p>File: <code>api/src/middleware/rate-limit.ts</code> (lines 1-45)</p> <pre><code>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</code></pre>"},{"location":"v2/architecture/authentication/#security-features","title":"Security Features","text":""},{"location":"v2/architecture/authentication/#1-user-enumeration-prevention","title":"1. User Enumeration Prevention","text":"<p>Problem: Attackers can enumerate valid emails by observing different error messages.</p> <p>Solution: Consistent 401 response for both \"user not found\" and \"invalid password\":</p> <pre><code>if (!user) {\n throw new Error('Invalid credentials'); // Same message\n}\n\nif (!isValidPassword) {\n throw new Error('Invalid credentials'); // Same message\n}\n</code></pre>"},{"location":"v2/architecture/authentication/#2-password-hashing","title":"2. Password Hashing","text":"<p>bcryptjs with automatic salt generation:</p> <pre><code>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</code></pre> <p>Rounds: 10 (balanced between security and performance)</p>"},{"location":"v2/architecture/authentication/#3-refresh-token-rotation","title":"3. Refresh Token Rotation","text":"<p>Prevents replay attacks:</p> <ul> <li>Old refresh token deleted immediately after use (atomic transaction)</li> <li>New refresh token issued with each refresh</li> <li>If old token reused \u2192 401 error</li> </ul>"},{"location":"v2/architecture/authentication/#4-token-expiration","title":"4. Token Expiration","text":"Token Type Lifetime Storage Purpose Access 15 minutes Not stored (JWT only) API authentication Refresh 7 days Database + localStorage Token renewal <p>Short access token lifetime limits damage if token is stolen.</p>"},{"location":"v2/architecture/authentication/#5-redis-authentication","title":"5. Redis Authentication","text":"<p>Redis requires password authentication:</p> <pre><code># .env\nREDIS_PASSWORD=strong_password_here\n\n# Redis connection\nREDIS_URL=redis://:${REDIS_PASSWORD}@redis-changemaker:6379\n</code></pre>"},{"location":"v2/architecture/authentication/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/architecture/authentication/#login-fails-with-correct-password","title":"Login Fails with Correct Password","text":"<p>Cause: User status not ACTIVE, or TEMP user expired.</p> <p>Solution:</p> <pre><code>-- 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</code></pre>"},{"location":"v2/architecture/authentication/#token-refresh-fails","title":"Token Refresh Fails","text":"<p>Cause: Refresh token not in database (deleted or expired).</p> <p>Solution:</p> <pre><code>-- 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</code></pre>"},{"location":"v2/architecture/authentication/#401-on-all-requests","title":"401 on All Requests","text":"<p>Cause: Access token missing, invalid, or expired.</p> <p>Debug:</p> <pre><code># 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</code></pre>"},{"location":"v2/architecture/authentication/#circular-dependency-authstore-apits","title":"Circular Dependency (auth.store \u2194 api.ts)","text":"<p>Problem: auth.store imports api.ts, api.ts imports auth.store (circular).</p> <p>Solution: Callback registration pattern (already implemented in api.ts lines 34-78).</p>"},{"location":"v2/architecture/authentication/#further-reading","title":"Further Reading","text":"<ul> <li>RBAC Patterns - Advanced role checks</li> <li>Security Model - Comprehensive security audit</li> <li>Database Schema - User and RefreshToken models</li> <li>Frontend State Management - Zustand auth store</li> <li>API Reference: Auth - Complete endpoint docs</li> </ul>"},{"location":"v2/architecture/dual-api/","title":"Dual API Architecture","text":"<p>Changemaker Lite V2 uses a dual API architecture with Express.js for main features and Fastify for the media library microservice.</p>"},{"location":"v2/architecture/dual-api/#why-dual-api","title":"Why Dual API?","text":""},{"location":"v2/architecture/dual-api/#performance-isolation","title":"Performance Isolation","text":"<p>Media operations (video processing, large uploads) are isolated from core platform features:</p> <ul> <li>Video uploads don't block campaign email sending</li> <li>Media job processing doesn't affect map rendering</li> <li>Large file transfers have separate connection pools</li> </ul>"},{"location":"v2/architecture/dual-api/#technology-evaluation","title":"Technology Evaluation","text":"<p>V2 evaluates two popular Node.js frameworks side-by-side:</p> 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":"<p>Each API can scale independently:</p> <ul> <li>Express API scales with user activity (campaigns, canvassing)</li> <li>Media API scales with video library size</li> <li>Horizontal scaling: run multiple instances behind nginx load balancer</li> </ul>"},{"location":"v2/architecture/dual-api/#clear-service-boundaries","title":"Clear Service Boundaries","text":"<p>Microservice preparation without full microservices complexity:</p> <ul> <li>Shared database (PostgreSQL 16)</li> <li>Shared cache (Redis)</li> <li>Separate codebases (<code>api/src/server.ts</code> vs <code>api/src/media-server.ts</code>)</li> <li>Future: Could split into separate repositories/deployments</li> </ul>"},{"location":"v2/architecture/dual-api/#architecture-diagram","title":"Architecture Diagram","text":"<pre><code>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</code></pre>"},{"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":"<p>File: <code>api/src/server.ts</code> (234 lines)</p> <pre><code>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</code></pre>"},{"location":"v2/architecture/dual-api/#key-features","title":"Key Features","text":"<p>14 Feature Modules:</p> <ol> <li>auth - JWT login, register, refresh, logout</li> <li>users - User CRUD with pagination + search</li> <li>settings - Site settings singleton</li> <li>campaigns - Campaign CRUD + public routes</li> <li>representatives - Represent API integration</li> <li>responses - Response wall + moderation + upvoting</li> <li>email-queue - BullMQ queue admin</li> <li>campaign-emails - Email tracking + stats</li> <li>postal-codes - Postal code cache</li> <li>locations - Location CRUD + geocoding + NAR import</li> <li>cuts - Cut (polygon) CRUD + spatial queries</li> <li>shifts - Shift CRUD + signups</li> <li>canvass - Volunteer canvassing (sessions, visits, routes)</li> <li>pages - Landing page builder (GrapesJS)</li> </ol> <p>Plus: email-templates, listmonk, pangolin, docs, qr, services, observability</p>"},{"location":"v2/architecture/dual-api/#architecture-pattern","title":"Architecture Pattern","text":"<p>Layered Structure:</p> <pre><code>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</code></pre> <p>Example: Campaign Module</p> <pre><code>// 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</code></pre>"},{"location":"v2/architecture/dual-api/#orm-prisma","title":"ORM: Prisma","text":"<p>27+ Models in <code>api/prisma/schema.prisma</code>:</p> <pre><code>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</code></pre> <p>Connection Pooling:</p> <p>Prisma manages connection pool automatically:</p> <pre><code>// 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</code></pre>"},{"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":"<p>File: <code>api/src/media-server.ts</code> (104 lines)</p> <pre><code>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</code></pre>"},{"location":"v2/architecture/dual-api/#key-features_1","title":"Key Features","text":"<p>4 Feature Modules:</p> <ol> <li>videos - Video CRUD, metadata, tags, deduplication</li> <li>shared-media - Public gallery categories (videos, curated, compilations, etc.)</li> <li>jobs - Job queue monitoring (pending, running, completed, failed)</li> <li>reactions - Reaction system (6 standard emojis: like, love, laugh, wow, sad, angry)</li> </ol>"},{"location":"v2/architecture/dual-api/#architecture-pattern_1","title":"Architecture Pattern","text":"<p>Plugin-Based:</p> <pre><code>// 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</code></pre>"},{"location":"v2/architecture/dual-api/#orm-drizzle","title":"ORM: Drizzle","text":"<p>Media Tables in <code>api/src/modules/media/db/schema.ts</code>:</p> <pre><code>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</code></pre> <p>Connection:</p> <p>Drizzle uses the same PostgreSQL connection pool:</p> <pre><code>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</code></pre>"},{"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":"<pre><code>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</code></pre>"},{"location":"v2/architecture/dual-api/#admin-media-upload","title":"Admin Media Upload","text":"<pre><code>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 }</code></pre> <p>Key Difference: - Express handles small JSON payloads (campaigns, locations, users) - Fastify handles large file uploads (streaming, no buffering)</p>"},{"location":"v2/architecture/dual-api/#shared-resources","title":"Shared Resources","text":""},{"location":"v2/architecture/dual-api/#postgresql-database","title":"PostgreSQL Database","text":"<p>Single Database, Multiple Schemas:</p> <ul> <li>Prisma Tables \u2014 Main schema (User, Campaign, Location, etc.)</li> <li>Drizzle Tables \u2014 Media schema (videos, jobs, reactions)</li> </ul> <p>Both ORMs connect to the same <code>changemaker_v2</code> database:</p> <pre><code>DATABASE_URL=postgresql://changemaker:password@v2-postgres:5432/changemaker_v2\n</code></pre> <p>No Conflicts: - Prisma manages its own schema via migrations (<code>npx prisma migrate</code>) - Drizzle manages media tables via <code>npx drizzle-kit push</code> - Tables don't overlap (different prefixes)</p>"},{"location":"v2/architecture/dual-api/#redis-cache","title":"Redis Cache","text":"<p>Both APIs use Redis for:</p> <ul> <li>Caching \u2014 Postal codes (Express), video metadata (Fastify)</li> <li>Rate Limiting \u2014 Redis-backed limits (Express: 30/hour, Fastify: 100/min)</li> <li>BullMQ Queues \u2014 Email queue (Express), job queue (Fastify)</li> </ul> <pre><code>// 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</code></pre>"},{"location":"v2/architecture/dual-api/#jwt-authentication","title":"JWT Authentication","text":"<p>Both APIs verify the same JWT tokens:</p> <pre><code>// 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</code></pre> <p>Shared Secret: <code>JWT_ACCESS_SECRET</code> environment variable</p>"},{"location":"v2/architecture/dual-api/#nginx-routing","title":"Nginx Routing","text":""},{"location":"v2/architecture/dual-api/#location-block-ordering","title":"Location Block Ordering","text":"<p>Critical: Media API location must come BEFORE general API location:</p> <pre><code>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</code></pre> <p>Why Order Matters:</p> <p>Nginx matches longest prefix first. If <code>/api/</code> came first, it would match <code>/api/media/videos</code> and route to Express (wrong).</p>"},{"location":"v2/architecture/dual-api/#subdomain-routing-production","title":"Subdomain Routing (Production)","text":"<pre><code># 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</code></pre>"},{"location":"v2/architecture/dual-api/#performance-comparison","title":"Performance Comparison","text":""},{"location":"v2/architecture/dual-api/#benchmarks-internal-testing","title":"Benchmarks (Internal Testing)","text":"<p>Simple GET Request (JSON response):</p> Framework Requests/sec Latency p95 Memory Express 12,500 35ms 150MB Fastify 28,000 15ms 120MB <p>Large Upload (1GB file):</p> Framework Upload Time Memory Peak CPU Usage Express 45s 450MB 85% Fastify 38s 280MB 60% <p>Real-World Usage:</p> <ul> <li>Express handles 95% of requests (campaigns, users, locations)</li> <li>Fastify handles 5% of requests (video uploads, media library)</li> <li>Both run comfortably on single-core containers</li> </ul>"},{"location":"v2/architecture/dual-api/#future-full-microservices","title":"Future: Full Microservices","text":"<p>The dual API design prepares for future microservices migration:</p>"},{"location":"v2/architecture/dual-api/#potential-split","title":"Potential Split","text":"<pre><code>\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</code></pre>"},{"location":"v2/architecture/dual-api/#benefits","title":"Benefits","text":"<ul> <li>Independent deployment \u2014 Ship campaign features without redeploying map</li> <li>Technology flexibility \u2014 Use Go for high-throughput, Python for ML</li> <li>Team ownership \u2014 Separate teams own separate services</li> <li>Fault isolation \u2014 Media service crash doesn't affect campaigns</li> </ul>"},{"location":"v2/architecture/dual-api/#trade-offs","title":"Trade-offs","text":"<ul> <li>Operational complexity \u2014 More containers, more monitoring</li> <li>Network latency \u2014 Inter-service calls over HTTP</li> <li>Data consistency \u2014 Distributed transactions harder</li> <li>Development overhead \u2014 Multiple repos, versioning</li> </ul> <p>V2 Strategy: Keep dual API until scaling requires split (likely 10,000+ users).</p>"},{"location":"v2/architecture/dual-api/#development-workflow","title":"Development Workflow","text":""},{"location":"v2/architecture/dual-api/#running-both-apis","title":"Running Both APIs","text":"<pre><code># 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</code></pre>"},{"location":"v2/architecture/dual-api/#docker-compose","title":"Docker Compose","text":"<pre><code># 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</code></pre>"},{"location":"v2/architecture/dual-api/#monitoring","title":"Monitoring","text":"<p>Both APIs expose Prometheus metrics:</p> <ul> <li>Express: <code>http://localhost:4000/api/metrics</code></li> <li>Fastify: <code>http://localhost:4100/metrics</code></li> </ul> <p>Custom Metrics:</p> <pre><code>// 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</code></pre> <p>Prometheus scrapes both endpoints every 15 seconds.</p>"},{"location":"v2/architecture/dual-api/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/architecture/dual-api/#media-api-returns-404","title":"Media API Returns 404","text":"<p>Cause: Nginx routing issue (order of location blocks).</p> <p>Fix: Ensure <code>/api/media/</code> comes BEFORE <code>/api/</code> in nginx config.</p>"},{"location":"v2/architecture/dual-api/#large-upload-fails-413","title":"Large Upload Fails (413)","text":"<p>Cause: <code>client_max_body_size</code> too small.</p> <p>Fix: Increase in nginx config:</p> <pre><code>location /api/media/ {\n client_max_body_size 20G; # Increase from default\n}\n</code></pre>"},{"location":"v2/architecture/dual-api/#connection-pool-exhausted","title":"Connection Pool Exhausted","text":"<p>Cause: Too many concurrent requests, not enough DB connections.</p> <p>Fix: Increase connection limit in <code>DATABASE_URL</code>:</p> <pre><code>DATABASE_URL=postgresql://user:pass@host:5432/db?connection_limit=20\n</code></pre> <p>Or reduce pool size per API instance (if running multiple):</p> <pre><code>// 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</code></pre>"},{"location":"v2/architecture/dual-api/#jwt-verification-fails-across-apis","title":"JWT Verification Fails Across APIs","text":"<p>Cause: Different <code>JWT_ACCESS_SECRET</code> values.</p> <p>Fix: Ensure both APIs use the same secret:</p> <pre><code># .env\nJWT_ACCESS_SECRET=<same-value-for-both>\n</code></pre>"},{"location":"v2/architecture/dual-api/#further-reading","title":"Further Reading","text":"<ul> <li>Database Architecture \u2014 Prisma vs Drizzle schemas</li> <li>Authentication Flow \u2014 JWT implementation</li> <li>Monitoring Stack \u2014 Prometheus metrics</li> <li>Nginx Configuration \u2014 Reverse proxy setup</li> <li>Scaling Strategies \u2014 Horizontal scaling</li> </ul>"},{"location":"v2/backend/","title":"Backend Overview","text":"<p>The Changemaker Lite V2 backend is a dual-API architecture built with TypeScript, providing a robust foundation for campaign management, mapping, and media services.</p>"},{"location":"v2/backend/#architecture","title":"Architecture","text":"<p>The backend consists of two complementary API servers:</p> <ul> <li>Express API (Port 4000) - Main V2 features with Prisma ORM + PostgreSQL</li> <li>Fastify Media API (Port 4100) - Video library with Drizzle ORM (shared database)</li> </ul> <p>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.</p>"},{"location":"v2/backend/#key-components","title":"Key Components","text":""},{"location":"v2/backend/#modules","title":"Modules","text":"<p>Backend modules provide feature-specific functionality across authentication, campaigns, locations, media, and more. Each module follows a consistent pattern with schemas, services, and routes.</p>"},{"location":"v2/backend/#services","title":"Services","text":"<p>Shared services provide cross-cutting concerns like email delivery, geocoding, queue management, and external API integrations.</p>"},{"location":"v2/backend/#middleware","title":"Middleware","text":"<p>Middleware components handle authentication, authorization, rate limiting, validation, and error handling across all API endpoints.</p>"},{"location":"v2/backend/#utilities","title":"Utilities","text":"<p>Utility modules provide common functionality for spatial calculations, logging, metrics collection, and data processing.</p>"},{"location":"v2/backend/#technology-stack","title":"Technology Stack","text":"<ul> <li>Runtime: Node.js 20+ with TypeScript 5.x</li> <li>Main Framework: Express.js (TypeScript)</li> <li>Media Framework: Fastify (TypeScript)</li> <li>ORMs:</li> <li>Prisma (main API)</li> <li>Drizzle (media API)</li> <li>Database: PostgreSQL 16</li> <li>Cache/Queue: Redis 7 with BullMQ</li> <li>Validation: Zod schemas</li> <li>Authentication: JWT with bcrypt</li> </ul>"},{"location":"v2/backend/#api-structure","title":"API Structure","text":"<pre><code>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</code></pre>"},{"location":"v2/backend/#related-documentation","title":"Related Documentation","text":"<ul> <li>Architecture Overview</li> <li>Database Schema</li> <li>API Reference</li> <li>Development Guide</li> </ul>"},{"location":"v2/backend/#quick-links","title":"Quick Links","text":"<ul> <li>Authentication Module</li> <li>Campaign Management</li> <li>Location Services</li> <li>Media Management</li> <li>Email Service</li> <li>Geocoding Service</li> </ul>"},{"location":"v2/backend/middleware/","title":"Backend Middleware","text":"<p>Middleware components provide cross-cutting concerns for authentication, authorization, validation, rate limiting, and error handling across all API endpoints.</p>"},{"location":"v2/backend/middleware/#middleware-architecture","title":"Middleware Architecture","text":"<p>Express middleware functions are composed in the request pipeline to:</p> <ul> <li>Authenticate users via JWT tokens</li> <li>Authorize access based on user roles</li> <li>Validate request bodies against Zod schemas</li> <li>Apply rate limits to prevent abuse</li> <li>Handle errors consistently</li> <li>Log requests and responses</li> </ul>"},{"location":"v2/backend/middleware/#core-middleware","title":"Core Middleware","text":""},{"location":"v2/backend/middleware/#authentication","title":"Authentication","text":"<p>authenticate (<code>middleware/auth.ts</code>)</p> <ul> <li>Verifies JWT access tokens from Authorization header</li> <li>Attaches <code>req.user</code> object with user ID, email, and role</li> <li>Returns 401 Unauthorized for missing/invalid tokens</li> <li>Supports both admin and public route protection</li> </ul> <pre><code>router.get('/profile', authenticate, async (req, res) => {\n // req.user is guaranteed to exist\n const userId = req.user.id;\n});\n</code></pre>"},{"location":"v2/backend/middleware/#authorization","title":"Authorization","text":"<p>requireRole (<code>middleware/auth.ts</code>)</p> <ul> <li>Checks if authenticated user has one of the required roles</li> <li>Returns 403 Forbidden if role check fails</li> <li>Supports multiple roles (OR logic)</li> </ul> <pre><code>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</code></pre> <p>requireNonTemp (<code>middleware/auth.ts</code>)</p> <ul> <li>Blocks TEMP users from accessing endpoints</li> <li>Used for routes that require full user accounts</li> <li>TEMP users are created during shift signups</li> </ul> <pre><code>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</code></pre>"},{"location":"v2/backend/middleware/#validation","title":"Validation","text":"<p>validate (<code>middleware/validate.ts</code>)</p> <ul> <li>Validates request body against Zod schemas</li> <li>Returns 400 Bad Request with sanitized error messages</li> <li>Supports nested validation and type coercion</li> <li>Prevents injection attacks by sanitizing output</li> </ul> <pre><code>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</code></pre>"},{"location":"v2/backend/middleware/#rate-limiting","title":"Rate Limiting","text":"<p>rateLimit (<code>middleware/rate-limit.ts</code>)</p> <ul> <li>Uses Redis for distributed rate limiting</li> <li>Configurable window size and max requests</li> <li>Returns 429 Too Many Requests when limit exceeded</li> <li>Per-IP tracking with X-RateLimit headers</li> </ul> <p>Common Configurations:</p> <pre><code>// 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</code></pre>"},{"location":"v2/backend/middleware/#error-handling","title":"Error Handling","text":"<p>errorHandler (<code>middleware/error-handler.ts</code>)</p> <ul> <li>Catches all unhandled errors in routes</li> <li>Formats errors consistently as JSON</li> <li>Logs errors with Winston</li> <li>Sanitizes error messages in production</li> <li>Returns appropriate HTTP status codes</li> </ul>"},{"location":"v2/backend/middleware/#request-logging","title":"Request Logging","text":"<p>requestLogger (<code>middleware/logger.ts</code>)</p> <ul> <li>Logs all incoming requests with Morgan</li> <li>Tracks response times</li> <li>Formats logs with Winston</li> <li>Separates error logs from access logs</li> </ul>"},{"location":"v2/backend/middleware/#middleware-composition","title":"Middleware Composition","text":"<p>Middleware is applied in order and can be composed:</p> <pre><code>// 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</code></pre>"},{"location":"v2/backend/middleware/#security-features","title":"Security Features","text":""},{"location":"v2/backend/middleware/#password-policy","title":"Password Policy","text":"<ul> <li>Enforced at schema validation level</li> <li>12+ characters required</li> <li>Must include uppercase, lowercase, and digit</li> <li>Validated in <code>auth.schemas.ts</code></li> </ul>"},{"location":"v2/backend/middleware/#user-enumeration-prevention","title":"User Enumeration Prevention","text":"<ul> <li>Returns 401 instead of 404 for missing users</li> <li>Consistent response times for invalid credentials</li> <li>No detailed error messages about which field failed</li> </ul>"},{"location":"v2/backend/middleware/#refresh-token-rotation","title":"Refresh Token Rotation","text":"<ul> <li>Atomic transaction to prevent race conditions</li> <li>Invalidates old refresh token on rotation</li> <li>Tracks token family for security</li> <li>Automatic cleanup of expired tokens</li> </ul>"},{"location":"v2/backend/middleware/#redis-authentication","title":"Redis Authentication","text":"<ul> <li>All Redis connections require password</li> <li>Configured via <code>REDIS_PASSWORD</code> environment variable</li> <li>Prevents unauthorized cache/queue access</li> </ul>"},{"location":"v2/backend/middleware/#middleware-chain","title":"Middleware Chain","text":"<p>Typical middleware chain for protected routes:</p> <ol> <li>CORS - Handle cross-origin requests</li> <li>Helmet - Security headers</li> <li>Request Logger - Log incoming request</li> <li>Body Parser - Parse JSON body</li> <li>Rate Limit - Check rate limits</li> <li>Authenticate - Verify JWT token</li> <li>Authorize - Check user role</li> <li>Validate - Validate request schema</li> <li>Route Handler - Execute business logic</li> <li>Error Handler - Catch and format errors</li> </ol>"},{"location":"v2/backend/middleware/#related-documentation","title":"Related Documentation","text":"<ul> <li>Backend Overview</li> <li>Authentication Module</li> <li>Security Audit</li> <li>Environment Variables</li> <li>Rate Limiting</li> </ul>"},{"location":"v2/backend/modules/","title":"Backend Modules","text":"<p>Backend modules provide feature-specific functionality for the Changemaker Lite platform. Each module follows a consistent architecture pattern with schemas, services, and routes.</p>"},{"location":"v2/backend/modules/#module-architecture","title":"Module Architecture","text":"<p>Each module typically contains:</p> <ul> <li>Schemas (<code>*.schemas.ts</code>) - Zod validation schemas for requests/responses</li> <li>Service (<code>*.service.ts</code>) - Business logic and database operations</li> <li>Routes (<code>*.routes.ts</code>) - Express router definitions with middleware</li> <li>Types - TypeScript interfaces (when needed beyond Prisma types)</li> </ul> <p>Modules may split routes into admin and public variants (e.g., <code>campaigns.routes.ts</code> and <code>campaigns-public.routes.ts</code>).</p>"},{"location":"v2/backend/modules/#core-modules","title":"Core Modules","text":""},{"location":"v2/backend/modules/#authentication-user-management","title":"Authentication & User Management","text":"<ul> <li>Auth Module - JWT authentication, login, register, refresh tokens, logout</li> <li>Users Module - User CRUD, pagination, search, role management</li> <li>Settings Module - Global site settings singleton</li> </ul>"},{"location":"v2/backend/modules/#influence-advocacy-campaigns","title":"Influence (Advocacy Campaigns)","text":"<ul> <li>Campaigns Module - Campaign CRUD, targeting, public views</li> <li>Representatives Module - Represent API integration, representative cache</li> <li>Responses Module - Response wall, moderation, verification, upvoting</li> </ul>"},{"location":"v2/backend/modules/#map-location-services","title":"Map & Location Services","text":"<ul> <li>Locations Module - Location CRUD, geocoding, NAR import, CSV operations</li> <li>Cuts Module - Polygon CRUD, spatial queries, point-in-polygon</li> <li>Shifts Module - Shift CRUD, volunteer signups, email notifications</li> <li>Canvass Module - Canvassing sessions, visit tracking, walking routes</li> </ul>"},{"location":"v2/backend/modules/#content-management","title":"Content Management","text":"<ul> <li>Pages Module - Landing page CRUD, block library, MkDocs export</li> <li>Email Templates Module - Template CRUD, variable processing, versioning</li> <li>Media Module - Video library, upload, metadata, reactions (Fastify API)</li> </ul>"},{"location":"v2/backend/modules/#supporting-modules","title":"Supporting Modules","text":""},{"location":"v2/backend/modules/#infrastructure","title":"Infrastructure","text":"<ul> <li>Services Module - Service health checks and monitoring</li> <li>QR Module - QR code PNG generation</li> <li>Docs Module - MkDocs and Code Server integration</li> </ul>"},{"location":"v2/backend/modules/#integrations","title":"Integrations","text":"<ul> <li>Listmonk Module - Newsletter sync, list management</li> <li>Pangolin Module - Tunnel management, resource configuration</li> <li>Observability Module - Prometheus/Grafana integration</li> </ul>"},{"location":"v2/backend/modules/#email-queuing","title":"Email & Queuing","text":"<ul> <li>Campaign Emails Module - Email tracking, statistics</li> <li>Email Queue Module - BullMQ queue administration</li> <li>Postal Codes Module - Postal code caching service</li> </ul>"},{"location":"v2/backend/modules/#geocoding-spatial","title":"Geocoding & Spatial","text":"<ul> <li>Geocoding Module - Multi-provider geocoding (6 providers)</li> <li>Tracking Module - GPS tracking sessions (volunteer + admin)</li> <li>Map Settings Module - Map configuration singleton</li> </ul>"},{"location":"v2/backend/modules/#module-list","title":"Module List","text":"Module Purpose Routes auth Authentication & sessions <code>/api/auth/*</code> users User management <code>/api/users/*</code> settings Site settings <code>/api/settings/*</code> campaigns Advocacy campaigns <code>/api/campaigns/*</code> representatives Representative lookup <code>/api/representatives/*</code> responses Response wall <code>/api/responses/*</code> locations Location database <code>/api/locations/*</code> cuts Geographic cuts <code>/api/cuts/*</code> shifts Volunteer shifts <code>/api/shifts/*</code> canvass Canvassing system <code>/api/canvass/*</code> pages Landing pages <code>/api/pages/*</code> media Video library <code>/media-api/*</code> (port 4100)"},{"location":"v2/backend/modules/#related-documentation","title":"Related Documentation","text":"<ul> <li>Backend Overview</li> <li>Services</li> <li>Middleware</li> <li>Database Models</li> <li>API Reference</li> </ul>"},{"location":"v2/backend/modules/auth/","title":"Auth Module","text":""},{"location":"v2/backend/modules/auth/#overview","title":"Overview","text":"<p>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.</p> <p>Key Features:</p> <ul> <li>JWT access tokens (15-minute expiry)</li> <li>Refresh token rotation with atomic transactions</li> <li>Password policy enforcement (12+ characters, complexity requirements)</li> <li>Rate limiting (10 requests/minute per IP)</li> <li>User enumeration prevention</li> <li>Account status validation (ACTIVE, SUSPENDED, BANNED)</li> <li>Temporary user expiration handling</li> <li>Prometheus metrics integration</li> </ul>"},{"location":"v2/backend/modules/auth/#file-paths","title":"File Paths","text":"File Purpose <code>api/src/modules/auth/auth.routes.ts</code> Express router with 5 endpoints <code>api/src/modules/auth/auth.service.ts</code> Authentication business logic <code>api/src/modules/auth/auth.schemas.ts</code> 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":"<pre><code>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</code></pre>"},{"location":"v2/backend/modules/auth/#refreshtoken-model","title":"RefreshToken Model","text":"<pre><code>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</code></pre>"},{"location":"v2/backend/modules/auth/#api-endpoints","title":"API Endpoints","text":"Method Path Auth Rate Limit Description POST <code>/api/auth/login</code> None 10/min Authenticate user with email/password POST <code>/api/auth/register</code> None 10/min Create new user account POST <code>/api/auth/refresh</code> None 10/min Refresh access token POST <code>/api/auth/logout</code> None 10/min Invalidate refresh token GET <code>/api/auth/me</code> 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":"<p>Authenticate user with email and password.</p> <p>Request Body:</p> <pre><code>{\n \"email\": \"user@example.com\",\n \"password\": \"SecurePass123\"\n}\n</code></pre> <p>Response (200 OK):</p> <pre><code>{\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</code></pre> <p>Error Responses:</p> <ul> <li><code>401 Unauthorized</code>: Invalid email or password (prevents user enumeration)</li> <li><code>403 Forbidden</code>: Account is suspended/banned or expired</li> <li><code>429 Too Many Requests</code>: Rate limit exceeded</li> </ul> <p>Implementation:</p> <pre><code>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</code></pre> <p>Security Features:</p> <ol> <li>User Enumeration Prevention: Same error message for invalid email or password</li> <li>Account Status Validation: Checks ACTIVE, SUSPENDED, BANNED, expired states</li> <li>Login Metrics: Records success/failure for monitoring</li> <li>Last Login Tracking: Updates <code>lastLoginAt</code> timestamp</li> <li>Password Comparison: Uses bcrypt with 12 salt rounds</li> </ol>"},{"location":"v2/backend/modules/auth/#post-apiauthregister","title":"POST /api/auth/register","text":"<p>Create a new user account. Public endpoint restricted to USER role.</p> <p>Request Body:</p> <pre><code>{\n \"email\": \"newuser@example.com\",\n \"password\": \"SecurePass123\",\n \"name\": \"Jane Smith\",\n \"phone\": \"+1234567890\"\n}\n</code></pre> <p>Response (201 Created):</p> <pre><code>{\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</code></pre> <p>Error Responses:</p> <ul> <li><code>409 Conflict</code>: Email already registered</li> <li><code>400 Bad Request</code>: Password doesn't meet complexity requirements</li> <li><code>429 Too Many Requests</code>: Rate limit exceeded</li> </ul> <p>Password Policy:</p> <pre><code>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</code></pre> <p>Security Notes:</p> <ul> <li>Role is always set to <code>USER</code> server-side (not user-controllable)</li> <li>Password hashed with bcrypt (12 salt rounds)</li> <li>Immediately issues access + refresh tokens (auto-login after registration)</li> </ul>"},{"location":"v2/backend/modules/auth/#post-apiauthrefresh","title":"POST /api/auth/refresh","text":"<p>Refresh access token using a valid refresh token. Implements token rotation for security.</p> <p>Request Body:</p> <pre><code>{\n \"refreshToken\": \"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...\"\n}\n</code></pre> <p>Response (200 OK):</p> <pre><code>{\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</code></pre> <p>Error Responses:</p> <ul> <li><code>401 Unauthorized</code>: Invalid, expired, or not found refresh token</li> </ul> <p>Token Rotation Flow:</p> <pre><code>// 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</code></pre> <p>Security Features:</p> <ol> <li>Atomic Rotation: Old token deleted and new token created in single transaction</li> <li>Expiration Check: Validates refresh token hasn't expired</li> <li>Database Validation: Checks token exists in database (prevents replay attacks)</li> <li>Automatic Cleanup: Expired tokens deleted on access attempt</li> </ol>"},{"location":"v2/backend/modules/auth/#post-apiauthlogout","title":"POST /api/auth/logout","text":"<p>Invalidate a refresh token.</p> <p>Request Body:</p> <pre><code>{\n \"refreshToken\": \"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...\"\n}\n</code></pre> <p>Response (200 OK):</p> <pre><code>{\n \"message\": \"Logged out\"\n}\n</code></pre> <p>Implementation:</p> <pre><code>async logout(refreshToken: string) {\n await prisma.refreshToken.deleteMany({ where: { token: refreshToken } });\n}\n</code></pre> <p>Notes:</p> <ul> <li>Uses <code>deleteMany</code> (safe if token doesn't exist)</li> <li>Client should discard access token immediately</li> <li>Access tokens remain valid until expiry (15 minutes)</li> </ul>"},{"location":"v2/backend/modules/auth/#get-apiauthme","title":"GET /api/auth/me","text":"<p>Get current authenticated user's profile.</p> <p>Request Headers:</p> <pre><code>Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...\n</code></pre> <p>Response (200 OK):</p> <pre><code>{\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</code></pre> <p>Error Responses:</p> <ul> <li><code>401 Unauthorized</code>: Missing, invalid, or expired access token</li> <li><code>401 Unauthorized</code>: User not found (prevents user enumeration - same code as invalid token)</li> </ul> <p>Security Note:</p> <p>Returns <code>401</code> instead of <code>404</code> when user not found to prevent user enumeration.</p>"},{"location":"v2/backend/modules/auth/#service-functions","title":"Service Functions","text":""},{"location":"v2/backend/modules/auth/#authserviceloginemail-password","title":"authService.login(email, password)","text":"<p>Purpose: Authenticate user and generate token pair.</p> <p>Flow:</p> <ol> <li>Find user by email</li> <li>Compare password with bcrypt</li> <li>Validate account status (ACTIVE, not expired)</li> <li>Record login metrics</li> <li>Update <code>lastLoginAt</code> timestamp</li> <li>Generate access + refresh token pair</li> <li>Return user (without password) + tokens</li> </ol> <p>Error Handling:</p> <pre><code>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</code></pre>"},{"location":"v2/backend/modules/auth/#authserviceregisterdata","title":"authService.register(data)","text":"<p>Purpose: Create new user account with hashed password.</p> <p>Flow:</p> <ol> <li>Check if email already exists</li> <li>Hash password with bcrypt (12 salt rounds)</li> <li>Create user with <code>USER</code> role</li> <li>Generate token pair</li> <li>Return user (without password) + tokens</li> </ol> <p>Implementation:</p> <pre><code>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</code></pre>"},{"location":"v2/backend/modules/auth/#authservicerefreshtokensrefreshtoken","title":"authService.refreshTokens(refreshToken)","text":"<p>Purpose: Rotate refresh token and issue new access token.</p> <p>Security:</p> <ul> <li>Atomic transaction (delete old + create new)</li> <li>Validates token signature with <code>JWT_REFRESH_SECRET</code></li> <li>Checks database for token existence</li> <li>Validates expiration timestamp</li> <li>Prevents replay attacks</li> </ul>"},{"location":"v2/backend/modules/auth/#authservicegenerateaccesstokenuser","title":"authService.generateAccessToken(user)","text":"<p>Purpose: Create short-lived JWT for API authentication.</p> <p>Token Payload:</p> <pre><code>interface TokenPayload {\n id: string;\n email: string;\n role: UserRole;\n}\n</code></pre> <p>Configuration:</p> <ul> <li>Secret: <code>JWT_ACCESS_SECRET</code> environment variable</li> <li>Expiry: <code>JWT_ACCESS_EXPIRY</code> (default: <code>15m</code>)</li> <li>Algorithm: HS256 (HMAC with SHA-256)</li> </ul> <p>Usage:</p> <pre><code>const accessToken = authService.generateAccessToken(user);\n// Returns: \"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...\"\n</code></pre>"},{"location":"v2/backend/modules/auth/#authservicegeneraterefreshtokenuser","title":"authService.generateRefreshToken(user)","text":"<p>Purpose: Create long-lived JWT and store in database.</p> <p>Configuration:</p> <ul> <li>Secret: <code>JWT_REFRESH_SECRET</code> (must differ from access secret)</li> <li>Expiry: <code>JWT_REFRESH_EXPIRY</code> (default: <code>7d</code>)</li> <li>Storage: Database (RefreshToken table)</li> </ul> <p>Implementation:</p> <pre><code>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</code></pre>"},{"location":"v2/backend/modules/auth/#code-examples","title":"Code Examples","text":""},{"location":"v2/backend/modules/auth/#complete-login-flow","title":"Complete Login Flow","text":"<pre><code>// 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</code></pre>"},{"location":"v2/backend/modules/auth/#token-refresh-flow","title":"Token Refresh Flow","text":"<pre><code>// 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</code></pre>"},{"location":"v2/backend/modules/auth/#protected-route-middleware","title":"Protected Route Middleware","text":"<pre><code>// 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</code></pre>"},{"location":"v2/backend/modules/auth/#role-based-access-control","title":"Role-Based Access Control","text":"<pre><code>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</code></pre>"},{"location":"v2/backend/modules/auth/#environment-configuration","title":"Environment Configuration","text":"<p>Required environment variables:</p> <pre><code># 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</code></pre> <p>Generate secrets:</p> <pre><code># Generate random secrets (macOS/Linux)\nopenssl rand -hex 32 # For JWT_ACCESS_SECRET\nopenssl rand -hex 32 # For JWT_REFRESH_SECRET (must differ!)\n</code></pre>"},{"location":"v2/backend/modules/auth/#security-considerations","title":"Security Considerations","text":""},{"location":"v2/backend/modules/auth/#password-policy","title":"Password Policy","text":"<ul> <li>Minimum length: 12 characters</li> <li>Complexity: Uppercase, lowercase, digit required</li> <li>Hashing: bcrypt with 12 salt rounds</li> <li>Enforcement: Schema-level validation (cannot be bypassed)</li> </ul>"},{"location":"v2/backend/modules/auth/#rate-limiting","title":"Rate Limiting","text":"<pre><code>// 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</code></pre>"},{"location":"v2/backend/modules/auth/#user-enumeration-prevention","title":"User Enumeration Prevention","text":"<ul> <li>Login/register errors don't reveal if email exists</li> <li><code>/api/auth/me</code> returns <code>401</code> (not <code>404</code>) when user not found</li> <li>Consistent error messages and response times</li> </ul>"},{"location":"v2/backend/modules/auth/#token-security","title":"Token Security","text":"<ul> <li>Access tokens: Short-lived (15 min), stored in memory</li> <li>Refresh tokens: Long-lived (7 days), stored in database + httpOnly cookie</li> <li>Rotation: Refresh tokens rotated on each use (atomic transaction)</li> <li>Secrets: Access and refresh use different secrets (prevents cross-contamination)</li> <li>Expiration: Automatic cleanup of expired tokens</li> </ul>"},{"location":"v2/backend/modules/auth/#database-security","title":"Database Security","text":"<ul> <li>Passwords never returned in API responses (excluded via <code>select</code> or destructuring)</li> <li>Refresh tokens cascade deleted when user deleted</li> <li>Unique constraint on <code>email</code> prevents duplicates</li> <li>Foreign key constraints ensure referential integrity</li> </ul>"},{"location":"v2/backend/modules/auth/#related-documentation","title":"Related Documentation","text":"<ul> <li>Architecture: Authentication - Auth flow diagrams</li> <li>Middleware: Auth - JWT verification middleware</li> <li>Middleware: RBAC - Role-based access control</li> <li>Middleware: Rate Limit - Rate limiting configuration</li> <li>Frontend: Auth Store - Zustand auth state management</li> <li>API Reference: Auth - Complete endpoint reference</li> <li>User Guide: Admin - Managing user accounts</li> <li>Security Audit - Feb 2026 security review</li> </ul>"},{"location":"v2/backend/modules/campaigns/","title":"Campaigns Module","text":""},{"location":"v2/backend/modules/campaigns/#overview","title":"Overview","text":"<p>The Campaigns module manages advocacy email campaigns targeting elected representatives. It provides comprehensive CRUD operations with rich feature flags, automatic slug generation, and role-based visibility controls. Campaigns integrate with the representative lookup system, email sending queue, and public response wall.</p> <p>Key Features:</p> <ul> <li>Full CRUD with pagination, search, and status filtering</li> <li>Auto-generated slugs from campaign titles (collision-safe)</li> <li>Feature flags (SMTP email, mailto links, response wall, highlighting, etc.)</li> <li>Government level targeting (Federal, Provincial, Municipal, School Board)</li> <li>Email count and call count tracking</li> <li>Public vs admin visibility (non-admins see only their own campaigns)</li> <li>Integration with email queue, representatives, and responses modules</li> <li>Cover photo support (URL-based)</li> </ul>"},{"location":"v2/backend/modules/campaigns/#file-paths","title":"File Paths","text":"File Purpose <code>api/src/modules/influence/campaigns/campaigns.routes.ts</code> Admin router with 5 CRUD endpoints <code>api/src/modules/influence/campaigns/campaigns-public.routes.ts</code> Public router (2 endpoints, no auth) <code>api/src/modules/influence/campaigns/campaigns.service.ts</code> Campaign business logic <code>api/src/modules/influence/campaigns/campaigns.schemas.ts</code> Zod validation schemas"},{"location":"v2/backend/modules/campaigns/#database-model","title":"Database Model","text":"<pre><code>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</code></pre>"},{"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 <code>/api/campaigns</code> Admin roles List campaigns with pagination/filters GET <code>/api/campaigns/:id</code> Admin roles Get single campaign by ID POST <code>/api/campaigns</code> Admin roles Create new campaign PUT <code>/api/campaigns/:id</code> Admin roles Update campaign DELETE <code>/api/campaigns/:id</code> Admin roles Delete campaign <p>Admin Roles: <code>SUPER_ADMIN</code>, <code>INFLUENCE_ADMIN</code>, <code>MAP_ADMIN</code></p>"},{"location":"v2/backend/modules/campaigns/#public-endpoints-no-authentication","title":"Public Endpoints (No Authentication)","text":"Method Path Auth Description GET <code>/api/public/campaigns</code> None List active/highlighted campaigns GET <code>/api/public/campaigns/:slug</code> 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":"<p>List campaigns with pagination, search, and filtering. Non-admin users see only their own campaigns.</p> <p>Query Parameters:</p> Parameter Type Required Default Description page number No 1 Page number limit number No 20 Results per page (max 100) search string No - Search title or description status CampaignStatus No - Filter by status <p>Example Request:</p> <pre><code>curl -H \"Authorization: Bearer <token>\" \\\n \"http://api.cmlite.org/api/campaigns?page=1&limit=10&search=climate&status=ACTIVE\"\n</code></pre> <p>Response (200 OK):</p> <pre><code>{\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</code></pre> <p>Visibility Rules:</p> <pre><code>// 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</code></pre>"},{"location":"v2/backend/modules/campaigns/#post-apicampaigns","title":"POST /api/campaigns","text":"<p>Create new campaign with auto-generated slug.</p> <p>Request Body:</p> <pre><code>{\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</code></pre> <p>Response (201 Created):</p> <p>Returns created campaign object (same format as GET).</p> <p>Slug Generation:</p> <pre><code>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</code></pre> <p>Example Slug Transformations:</p> <ul> <li><code>\"Climate Action NOW!\"</code> \u2192 <code>climate-action-now</code></li> <li><code>\"Email Your MP: Support Bill C-12\"</code> \u2192 <code>email-your-mp-support-bill-c-12</code></li> <li><code>\"Climate Action Now\"</code> (2<sup>nd</sup> with same title) \u2192 <code>climate-action-now-2</code></li> </ul>"},{"location":"v2/backend/modules/campaigns/#put-apicampaignsid","title":"PUT /api/campaigns/:id","text":"<p>Update campaign. Partial updates supported. Slug regenerated if title changes.</p> <p>Request Body (Partial):</p> <pre><code>{\n \"status\": \"ACTIVE\",\n \"highlightCampaign\": true,\n \"showResponseWall\": true\n}\n</code></pre> <p>Response (200 OK):</p> <p>Returns updated campaign object.</p>"},{"location":"v2/backend/modules/campaigns/#delete-apicampaignsid","title":"DELETE /api/campaigns/:id","text":"<p>Delete campaign and cascade to related records.</p> <p>Response (204 No Content):</p> <p>No response body.</p> <p>Cascading Deletes:</p> <ul> <li>Campaign emails (all email send records)</li> <li>Responses (all user responses)</li> <li>Custom recipients</li> </ul>"},{"location":"v2/backend/modules/campaigns/#public-endpoint-details","title":"Public Endpoint Details","text":""},{"location":"v2/backend/modules/campaigns/#get-apipubliccampaigns","title":"GET /api/public/campaigns","text":"<p>List active and highlighted campaigns (no auth required).</p> <p>Query Parameters:</p> Parameter Type Description highlighted boolean Filter to highlighted campaigns only limit number Results per page (max 50, default 20) <p>Example Request:</p> <pre><code>curl http://api.cmlite.org/api/public/campaigns?highlighted=true&limit=10\n</code></pre> <p>Response (200 OK):</p> <pre><code>{\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</code></pre> <p>Filtering:</p> <pre><code>const where: Prisma.CampaignWhereInput = {\n status: CampaignStatus.ACTIVE, // Only active campaigns\n};\n\nif (highlighted === 'true') {\n where.highlightCampaign = true;\n}\n</code></pre>"},{"location":"v2/backend/modules/campaigns/#get-apipubliccampaignsslug","title":"GET /api/public/campaigns/:slug","text":"<p>Get campaign by slug (no auth required).</p> <p>Path Parameters:</p> <ul> <li><code>slug</code> (string): Campaign slug</li> </ul> <p>Example Request:</p> <pre><code>curl http://api.cmlite.org/api/public/campaigns/climate-action-now\n</code></pre> <p>Response (200 OK):</p> <p>Returns full campaign object (same as admin GET).</p> <p>Error Responses:</p> <ul> <li><code>404 Not Found</code>: Campaign not found or not ACTIVE</li> </ul>"},{"location":"v2/backend/modules/campaigns/#service-functions","title":"Service Functions","text":""},{"location":"v2/backend/modules/campaigns/#campaignsservicefindallfilters-user","title":"campaignsService.findAll(filters, user)","text":"<p>List campaigns with role-based visibility.</p> <p>Visibility Logic:</p> <pre><code>// 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</code></pre>"},{"location":"v2/backend/modules/campaigns/#campaignsservicecreatedata-user","title":"campaignsService.create(data, user)","text":"<p>Create campaign with auto-generated slug and creator tracking.</p> <p>Creator Tracking:</p> <pre><code>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</code></pre>"},{"location":"v2/backend/modules/campaigns/#campaignsserviceupdateid-data","title":"campaignsService.update(id, data)","text":"<p>Update campaign. Regenerates slug if title changes.</p> <p>Slug Regeneration:</p> <pre><code>if (data.title) {\n const newSlug = generateSlug(data.title);\n updateData.slug = await resolveSlugCollision(newSlug, id);\n}\n</code></pre>"},{"location":"v2/backend/modules/campaigns/#validation-schemas","title":"Validation Schemas","text":""},{"location":"v2/backend/modules/campaigns/#create-campaign-schema","title":"Create Campaign Schema","text":"<pre><code>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</code></pre>"},{"location":"v2/backend/modules/campaigns/#feature-flags","title":"Feature Flags","text":"Flag Default Description <code>allowSmtpEmail</code> <code>true</code> Enable direct SMTP email sending via queue <code>allowMailtoLink</code> <code>true</code> Show mailto: link option (opens default email client) <code>collectUserInfo</code> <code>true</code> Collect sender name, email, postal code <code>showEmailCount</code> <code>true</code> Display email send count on public page <code>showCallCount</code> <code>true</code> Display call count (future feature) <code>allowEmailEditing</code> <code>false</code> Let users edit email template before sending <code>allowCustomRecipients</code> <code>false</code> Allow manual recipient selection (overrides postal code lookup) <code>showResponseWall</code> <code>false</code> Enable public response submission + display <code>highlightCampaign</code> <code>false</code> 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":"<pre><code>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</code></pre>"},{"location":"v2/backend/modules/campaigns/#public-list-active-campaigns","title":"Public: List Active Campaigns","text":"<pre><code>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</code></pre>"},{"location":"v2/backend/modules/campaigns/#admin-update-campaign-status","title":"Admin: Update Campaign Status","text":"<pre><code>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</code></pre>"},{"location":"v2/backend/modules/campaigns/#frontend-integration","title":"Frontend Integration","text":"<p>The CampaignsPage component (<code>admin/src/pages/CampaignsPage.tsx</code>) provides:</p> <ul> <li>Paginated table with search and status filter</li> <li>Feature flag badges (SMTP, Response Wall, Highlighted, etc.)</li> <li>Create campaign modal with rich text editor (TinyMCE/Quill)</li> <li>Edit campaign modal (pre-populated form)</li> <li>Delete confirmation modal</li> <li>Email count drawer (shows campaign email stats)</li> <li>Publish/archive actions (status toggle)</li> </ul> <p>State Management:</p> <pre><code>const [campaigns, setCampaigns] = useState<Campaign[]>([]);\nconst [pagination, setPagination] = useState({ page: 1, limit: 20, total: 0 });\nconst [filters, setFilters] = useState({ search: '', status: null });\n</code></pre>"},{"location":"v2/backend/modules/campaigns/#related-documentation","title":"Related Documentation","text":"<ul> <li>Representatives Module - Postal code \u2192 rep lookup</li> <li>Responses Module - Response wall + moderation</li> <li>Campaign Emails Module - Email tracking</li> <li>Email Queue Module - BullMQ email sending</li> <li>Frontend: CampaignsPage - Campaign management UI</li> <li>Frontend: Public Campaign Page - Public campaign view</li> <li>API Reference: Campaigns - Complete endpoint reference</li> <li>User Guide: Campaign Manager - Creating campaigns guide</li> </ul>"},{"location":"v2/backend/modules/canvass/","title":"Canvass Module","text":""},{"location":"v2/backend/modules/canvass/#overview","title":"Overview","text":"<p>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.</p> <p>Key Features:</p> <ul> <li>Canvass session management (start, end, abandon detection)</li> <li>Visit recording with outcomes (CONTACTED, SUPPORTER, NOT_HOME, REFUSED, etc.)</li> <li>Bulk visit recording (mark entire building as NOT_HOME)</li> <li>Walking route optimization (nearest-neighbor algorithm)</li> <li>GPS-enabled location tracking</li> <li>Role-gated field editing (volunteers update support data, admins update PII)</li> <li>Real-time cut completion percentage calculation</li> <li>Admin dashboard (stats, activity feed, volunteer leaderboard)</li> <li>Shift-based assignments (volunteers assigned to cuts via shifts)</li> <li>Rate limiting (30 visits/min per IP, 10 bulk visits/min)</li> <li>Abandoned session cleanup (ACTIVE > 12h \u2192 ABANDONED)</li> </ul>"},{"location":"v2/backend/modules/canvass/#file-paths","title":"File Paths","text":"File Purpose <code>api/src/modules/map/canvass/canvass.routes.ts</code> 2 routers (volunteer + admin) with 22 endpoints <code>api/src/modules/map/canvass/canvass.service.ts</code> Canvass business logic + session management <code>api/src/modules/map/canvass/canvass.schemas.ts</code> Zod validation schemas <code>api/src/modules/map/canvass/canvass-route.service.ts</code> Walking route optimization algorithm"},{"location":"v2/backend/modules/canvass/#database-models","title":"Database Models","text":""},{"location":"v2/backend/modules/canvass/#canvasssession","title":"CanvassSession","text":"<pre><code>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</code></pre>"},{"location":"v2/backend/modules/canvass/#canvassvisit","title":"CanvassVisit","text":"<pre><code>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</code></pre>"},{"location":"v2/backend/modules/canvass/#address-model-multi-unit-support","title":"Address Model (Multi-Unit Support)","text":"<pre><code>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</code></pre> <p>Multi-Unit Building Support:</p> <ul> <li><code>Location</code> \u2014 Physical building (lat/lng, address, buildingNotes)</li> <li><code>Address</code> \u2014 Individual unit within building (unitNumber, firstName, lastName, supportLevel, etc.)</li> <li><code>CanvassVisit</code> \u2014 Links to <code>Address</code> (not <code>Location</code>) for per-unit tracking</li> </ul>"},{"location":"v2/backend/modules/canvass/#api-endpoints","title":"API Endpoints","text":""},{"location":"v2/backend/modules/canvass/#volunteer-endpoints-authentication-required-any-role","title":"Volunteer Endpoints (Authentication Required, Any Role)","text":"Method Path Description GET <code>/api/map/canvass/my/assignments</code> Get assigned shifts with cuts GET <code>/api/map/canvass/my/stats</code> Get volunteer statistics GET <code>/api/map/canvass/my/visits</code> List my visit history (paginated) GET <code>/api/map/canvass/my/session</code> Get active canvass session POST <code>/api/map/canvass/sessions</code> Start new canvass session POST <code>/api/map/canvass/sessions/:id/end</code> End canvass session GET <code>/api/map/canvass/cuts/:cutId/locations</code> Get locations in cut for canvassing GET <code>/api/map/canvass/cuts/:cutId/route</code> Get optimized walking route GET <code>/api/map/canvass/locations</code> Get all locations with visit annotations PUT <code>/api/map/canvass/locations/:id</code> Update location (role-gated fields) POST <code>/api/map/canvass/locations</code> Create location (role-gated fields) POST <code>/api/map/canvass/reverse-geocode</code> Reverse geocode lat/lng POST <code>/api/map/canvass/geocode-search</code> Geocode address for map search POST <code>/api/map/canvass/visits</code> Record visit (rate-limited: 30/min) POST <code>/api/map/canvass/visits/bulk</code> 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 <code>/api/map/canvass/stats</code> Get admin statistics GET <code>/api/map/canvass/stats/cuts/:cutId</code> Get cut-specific statistics GET <code>/api/map/canvass/activity</code> Get recent activity feed (paginated) GET <code>/api/map/canvass/volunteers</code> List volunteers with visit counts GET <code>/api/map/canvass/volunteers/:userId</code> Get volunteer statistics GET <code>/api/map/canvass/visits</code> List all visits (paginated, filtered) <p>Admin Roles: <code>SUPER_ADMIN</code>, <code>MAP_ADMIN</code></p>"},{"location":"v2/backend/modules/canvass/#volunteer-endpoint-details","title":"Volunteer Endpoint Details","text":""},{"location":"v2/backend/modules/canvass/#post-apimapcanvasssessions","title":"POST /api/map/canvass/sessions","text":"<p>Start new canvass session for a cut.</p> <p>Request Body:</p> <pre><code>{\n \"cutId\": \"clxCut123\",\n \"shiftId\": \"clxShift456\",\n \"startLatitude\": 43.6532,\n \"startLongitude\": -79.3832\n}\n</code></pre> <p>Response (201 Created):</p> <pre><code>{\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</code></pre> <p>Validation:</p> <ul> <li>Only one active session per user allowed</li> <li>Cut must exist</li> <li>Shift is optional (can canvass outside scheduled shifts)</li> </ul> <p>Error Responses:</p> <ul> <li><code>409 Conflict</code>: User already has active session</li> <li><code>404 Not Found</code>: Cut not found</li> </ul>"},{"location":"v2/backend/modules/canvass/#post-apimapcanvasssessionsidend","title":"POST /api/map/canvass/sessions/:id/end","text":"<p>End active canvass session.</p> <p>Path Parameters:</p> <ul> <li><code>id</code> (string): Session ID</li> </ul> <p>Response (200 OK):</p> <p>Returns updated session with <code>status: COMPLETED</code> and <code>endedAt</code> timestamp.</p> <p>Post-Processing:</p> <ul> <li>Recalculates cut completion percentage</li> <li>Updates Prometheus metrics (active sessions gauge)</li> </ul> <p>Validation:</p> <ul> <li>Session must belong to authenticated user</li> <li>Session must be ACTIVE (not already completed/abandoned)</li> </ul>"},{"location":"v2/backend/modules/canvass/#get-apimapcanvassmyassignments","title":"GET /api/map/canvass/my/assignments","text":"<p>Get volunteer's assigned shifts with associated cuts.</p> <p>Example Response (200 OK):</p> <pre><code>[\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</code></pre> <p>Filtering:</p> <ul> <li>Only returns confirmed signups (<code>status: CONFIRMED</code>)</li> <li>Only returns shifts with associated cuts (<code>cutId</code> not null)</li> <li>Ordered by shift date ascending (upcoming shifts first)</li> </ul>"},{"location":"v2/backend/modules/canvass/#get-apimapcanvasscutscutidlocations","title":"GET /api/map/canvass/cuts/:cutId/locations","text":"<p>Get locations within cut for canvassing with visit annotations.</p> <p>Path Parameters:</p> <ul> <li><code>cutId</code> (string): Cut ID</li> </ul> <p>Query Parameters:</p> <ul> <li><code>minLat</code>, <code>maxLat</code>, <code>minLng</code>, <code>maxLng</code> (optional): Bounding box for visible map area</li> </ul> <p>Example Response (200 OK):</p> <pre><code>[\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</code></pre> <p>Two-Stage Filtering:</p> <ol> <li>Database bounds filter \u2014 Fast WHERE clause on lat/lng</li> <li>Polygon filter \u2014 In-memory point-in-polygon check</li> </ol> <p>Visit Annotations:</p> <ul> <li><code>lastVisit</code> \u2014 Most recent visit to this address (any volunteer)</li> <li><code>isMyVisit</code> \u2014 True if authenticated user made last visit</li> <li>Null if address never visited</li> </ul>"},{"location":"v2/backend/modules/canvass/#get-apimapcanvasscutscutidroute","title":"GET /api/map/canvass/cuts/:cutId/route","text":"<p>Get optimized walking route for cut.</p> <p>Path Parameters:</p> <ul> <li><code>cutId</code> (string): Cut ID</li> </ul> <p>Query Parameters:</p> <ul> <li><code>excludeVisited</code> (boolean, default: false): Exclude already-visited addresses</li> <li><code>startLatitude</code> (number, optional): Starting position latitude</li> <li><code>startLongitude</code> (number, optional): Starting position longitude</li> </ul> <p>Example Response (200 OK):</p> <pre><code>{\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</code></pre> <p>Walking Route Algorithm:</p> <p>Nearest-neighbor greedy algorithm:</p> <pre><code>// 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</code></pre> <p>Performance:</p> <ul> <li>O(n\u00b2) complexity (acceptable for typical cut sizes <500 locations)</li> <li>Uses haversine distance (meters) for accurate walking distances</li> <li>Assumes walking speed: 1.4 m/s (5 km/h)</li> </ul>"},{"location":"v2/backend/modules/canvass/#post-apimapcanvassvisits","title":"POST /api/map/canvass/visits","text":"<p>Record visit to an address.</p> <p>Rate Limiting: 30 requests per minute per IP</p> <p>Request Body:</p> <pre><code>{\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</code></pre> <p>Field Descriptions:</p> <ul> <li><code>addressId</code> (required): Address ID (unit within building)</li> <li><code>outcome</code> (required): Visit outcome enum</li> <li><code>supportLevel</code> (optional): Support level identified during visit</li> <li><code>signRequested</code> (optional, default: false): Lawn sign requested</li> <li><code>signSize</code> (optional): Sign size if requested</li> <li><code>notes</code> (optional): Visit notes</li> <li><code>durationSeconds</code> (optional): Time spent at door</li> <li><code>sessionId</code> (optional): Active canvass session ID</li> <li><code>shiftId</code> (optional): Associated shift ID</li> <li><code>updateLocation</code> (optional, default: true): Update address record with visit data</li> </ul> <p>Response (201 Created):</p> <p>Returns created visit object.</p> <p>Address Update Logic:</p> <p>If <code>updateLocation=true</code> and outcome is <code>CONTACTED</code> or <code>SUPPORTER</code>:</p> <pre><code>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</code></pre> <p>Metrics:</p> <ul> <li>Increments <code>cm_canvass_visits_total</code> counter with outcome label</li> <li>Updates cut completion percentage</li> </ul>"},{"location":"v2/backend/modules/canvass/#post-apimapcanvassvisitsbulk","title":"POST /api/map/canvass/visits/bulk","text":"<p>Record visit to all unvisited units in a building.</p> <p>Rate Limiting: 10 requests per minute per IP (stricter than single visits)</p> <p>Request Body:</p> <pre><code>{\n \"locationId\": \"clxLocation456\",\n \"outcome\": \"NOT_HOME\",\n \"notes\": \"Building-wide: No answer at any unit\",\n \"sessionId\": \"clxSession789\",\n \"shiftId\": \"clxShift456\"\n}\n</code></pre> <p>Allowed Outcomes:</p> <p>Only non-contact outcomes: - <code>NOT_HOME</code> - <code>REFUSED</code> - <code>MOVED</code></p> <p>Logic:</p> <ol> <li>Find all addresses at location (building)</li> <li>Filter to unvisited addresses (no existing visit records)</li> <li>Create visit records for all unvisited addresses in bulk</li> </ol> <p>Response (201 Created):</p> <pre><code>{\n \"created\": 8,\n \"skipped\": 2,\n \"locationId\": \"clxLocation456\"\n}\n</code></pre> <p>Use Cases:</p> <ul> <li>Large apartment buildings where no one answers buzzer</li> <li>Entire building marked as MOVED (demolished/vacant)</li> <li>Save time: record 10+ units with single action</li> </ul>"},{"location":"v2/backend/modules/canvass/#put-apimapcanvasslocationsid","title":"PUT /api/map/canvass/locations/:id","text":"<p>Update location with role-gated field restrictions.</p> <p>Path Parameters:</p> <ul> <li><code>id</code> (string): Address ID</li> </ul> <p>Request Body (Volunteer):</p> <pre><code>{\n \"supportLevel\": \"LEVEL_2\",\n \"sign\": true,\n \"signSize\": \"Large\",\n \"notes\": \"Willing to volunteer\"\n}\n</code></pre> <p>Request Body (Admin):</p> <pre><code>{\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</code></pre> <p>Role-Gated Fields:</p> <p>All Authenticated Users: - <code>supportLevel</code> - <code>sign</code> - <code>signSize</code> - <code>notes</code></p> <p>Admins Only (<code>SUPER_ADMIN</code>, <code>MAP_ADMIN</code>): - <code>firstName</code> - <code>lastName</code> - <code>address</code> - <code>unitNumber</code> - <code>email</code> - <code>phone</code></p> <p>TEMP Users:</p> <ul> <li>Cannot update any fields (read-only canvassing)</li> </ul> <p>Service-Level Field Stripping:</p> <pre><code>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</code></pre>"},{"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":"<p>Get aggregate canvassing statistics.</p> <p>Example Response (200 OK):</p> <pre><code>{\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</code></pre>"},{"location":"v2/backend/modules/canvass/#get-apimapcanvassactivity","title":"GET /api/map/canvass/activity","text":"<p>Get recent canvass activity feed.</p> <p>Query Parameters:</p> <ul> <li><code>page</code> (default: 1): Page number</li> <li><code>limit</code> (default: 20, max: 100): Results per page</li> <li><code>cutId</code> (optional): Filter by cut</li> <li><code>userId</code> (optional): Filter by volunteer</li> <li><code>outcome</code> (optional): Filter by outcome</li> </ul> <p>Example Response (200 OK):</p> <pre><code>{\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</code></pre>"},{"location":"v2/backend/modules/canvass/#get-apimapcanvassvolunteers","title":"GET /api/map/canvass/volunteers","text":"<p>List volunteers with visit counts.</p> <p>Example Response (200 OK):</p> <pre><code>[\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</code></pre>"},{"location":"v2/backend/modules/canvass/#service-functions","title":"Service Functions","text":""},{"location":"v2/backend/modules/canvass/#canvassservicestartsessionuserid-data","title":"canvassService.startSession(userId, data)","text":"<p>Start new canvass session.</p> <p>Validation:</p> <pre><code>// 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</code></pre>"},{"location":"v2/backend/modules/canvass/#canvassserviceendsessionsessionid-userid","title":"canvassService.endSession(sessionId, userId)","text":"<p>End canvass session and recalculate cut completion.</p> <p>Post-Processing:</p> <pre><code>// 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</code></pre> <p>Cut Completion Calculation:</p> <pre><code>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</code></pre>"},{"location":"v2/backend/modules/canvass/#canvassservicerecordvisituserid-data","title":"canvassService.recordVisit(userId, data)","text":"<p>Record visit to address with optional location update.</p> <p>Address Update Logic:</p> <pre><code>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</code></pre> <p>Metrics:</p> <pre><code>recordCanvassVisit(data.outcome); // Prometheus counter\n</code></pre>"},{"location":"v2/backend/modules/canvass/#canvassservicegetwalkingroutecutid-userid-options","title":"canvassService.getWalkingRoute(cutId, userId, options)","text":"<p>Get optimized walking route for cut.</p> <p>Algorithm:</p> <pre><code>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</code></pre>"},{"location":"v2/backend/modules/canvass/#abandoned-session-cleanup","title":"Abandoned Session Cleanup","text":"<p>Scheduled Task:</p> <p>Runs on API startup and every hour:</p> <pre><code>// 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</code></pre>"},{"location":"v2/backend/modules/canvass/#validation-schemas","title":"Validation Schemas","text":""},{"location":"v2/backend/modules/canvass/#record-visit-schema","title":"Record Visit Schema","text":"<pre><code>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</code></pre>"},{"location":"v2/backend/modules/canvass/#bulk-record-visit-schema","title":"Bulk Record Visit Schema","text":"<pre><code>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</code></pre>"},{"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":"<pre><code>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</code></pre>"},{"location":"v2/backend/modules/canvass/#volunteer-record-visit","title":"Volunteer: Record Visit","text":"<pre><code>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</code></pre>"},{"location":"v2/backend/modules/canvass/#admin-get-canvass-statistics","title":"Admin: Get Canvass Statistics","text":"<pre><code>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</code></pre>"},{"location":"v2/backend/modules/canvass/#frontend-integration","title":"Frontend Integration","text":""},{"location":"v2/backend/modules/canvass/#volunteer-portal","title":"Volunteer Portal","text":"<p>VolunteerMapPage (<code>admin/src/pages/volunteer/VolunteerMapPage.tsx</code>):</p> <ul> <li>Full-screen Leaflet map (no AppLayout)</li> <li>GPS tracking (blue dot follows volunteer)</li> <li>Location markers (color-coded by visit status)</li> <li>Walking route visualization (dashed blue line)</li> <li>Bottom sheet toolbar (floating panel)</li> <li>Visit recording form (outcome, notes, duration)</li> <li>Optimized route toggle (exclude visited addresses)</li> <li>Session timer (displays elapsed time)</li> </ul> <p>MyAssignmentsPage (<code>admin/src/pages/volunteer/MyAssignmentsPage.tsx</code>):</p> <ul> <li>Assigned shifts table</li> <li>Cut names + completion percentage</li> <li>\"Start Canvassing\" button (opens map, starts session)</li> </ul> <p>MyActivityPage (<code>admin/src/pages/volunteer/MyActivityPage.tsx</code>):</p> <ul> <li>Visit history table (paginated)</li> <li>Outcome breakdown (pie chart)</li> <li>Today's visit count vs. total</li> </ul> <p>State Management:</p> <pre><code>// 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</code></pre>"},{"location":"v2/backend/modules/canvass/#admin-dashboard","title":"Admin Dashboard","text":"<p>CanvassDashboardPage (<code>admin/src/pages/CanvassDashboardPage.tsx</code>):</p> <ul> <li>Statistics cards (total visits, active sessions, volunteers, completion %)</li> <li>Recent activity feed (realtime visit stream)</li> <li>Cut progress table (completionPercentage, visitCount)</li> <li>Volunteer leaderboard (sorted by visit count)</li> </ul>"},{"location":"v2/backend/modules/canvass/#performance-considerations","title":"Performance Considerations","text":"<p>Rate Limiting:</p> <ul> <li>Single visits: 30/min per IP (prevents spam)</li> <li>Bulk visits: 10/min per IP (stricter for building-wide operations)</li> <li>Geocoding: 10/min per IP (prevents geocoding API abuse)</li> </ul> <p>Abandoned Session Cleanup:</p> <ul> <li>Runs hourly (low overhead)</li> <li>Only updates sessions older than 12 hours</li> <li>Prevents stale ACTIVE sessions blocking new sessions</li> </ul> <p>Walking Route Algorithm:</p> <ul> <li>O(n\u00b2) complexity acceptable for typical cuts (<500 locations)</li> <li>Uses haversine distance (meters) for accuracy</li> <li>Pre-filters visited addresses when <code>excludeVisited=true</code></li> </ul> <p>Cut Completion Calculation:</p> <ul> <li>Triggered on session end (not every visit)</li> <li>Uses <code>distinct: ['addressId']</code> to count unique addresses</li> <li>Caches result in <code>Cut.completionPercentage</code> field</li> </ul>"},{"location":"v2/backend/modules/canvass/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/backend/modules/canvass/#issue-you-already-have-an-active-canvass-session","title":"Issue: \"You already have an active canvass session\"","text":"<p>Cause: Volunteer forgot to end previous session</p> <p>Solution:</p> <ul> <li>Admin: Find session in CanvassDashboardPage, manually mark as COMPLETED</li> <li>Wait for automatic cleanup (12h timeout)</li> <li>Volunteer: Navigate to session end screen and click \"End Session\"</li> </ul>"},{"location":"v2/backend/modules/canvass/#issue-rate-limit-exceeded-429-when-recording-visits","title":"Issue: Rate limit exceeded (429) when recording visits","text":"<p>Cause: Recording visits too quickly (>30/min)</p> <p>Solution:</p> <ul> <li>Slow down visit recording (realistic door-knocking speed: ~10-15/hr)</li> <li>Use bulk visit endpoint for buildings (NOT_HOME for entire building)</li> </ul>"},{"location":"v2/backend/modules/canvass/#issue-walking-route-skips-some-addresses","title":"Issue: Walking route skips some addresses","text":"<p>Cause: <code>excludeVisited=true</code> filters out already-visited addresses</p> <p>Solution:</p> <ul> <li>Set <code>excludeVisited=false</code> to see all addresses</li> <li>Verify addresses have visits recorded (check <code>lastVisit</code> field)</li> </ul>"},{"location":"v2/backend/modules/canvass/#issue-cut-completion-percentage-not-updating","title":"Issue: Cut completion percentage not updating","text":"<p>Cause: Completion calculated on session end, not per-visit</p> <p>Solution:</p> <ul> <li>End canvass session to trigger recalculation</li> <li>Admin: View cut stats to verify visitCount vs. totalAddresses</li> </ul>"},{"location":"v2/backend/modules/canvass/#related-documentation","title":"Related Documentation","text":"<ul> <li>Shifts Module - Shift CRUD + signup system</li> <li>Cuts Module - Polygon filtering</li> <li>Locations Module - Location management</li> <li>Spatial Utils - Point-in-polygon, haversine distance</li> <li>Frontend: VolunteerMapPage - Canvassing map UI</li> <li>Frontend: CanvassDashboardPage - Admin dashboard</li> <li>API Reference: Canvass - Complete endpoint reference</li> <li>Feature: Volunteer Canvassing - Canvassing feature guide</li> </ul>"},{"location":"v2/backend/modules/locations/","title":"Locations Module","text":""},{"location":"v2/backend/modules/locations/#overview","title":"Overview","text":"<p>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.</p> <p>Key Features:</p> <ul> <li>Location CRUD with automatic geocoding</li> <li>Multi-provider geocoding (Nominatim, Mapbox, ArcGIS, Photon, Google, LocationIQ)</li> <li>Batch geocoding with BullMQ queue integration</li> <li>NAR 2025 bulk import (Canadian electoral data with Lambert projection support)</li> <li>CSV import/export with flexible column mapping</li> <li>Location history tracking (audit trail for all changes)</li> <li>Reverse geocoding (lat/lng \u2192 address)</li> <li>Spatial filtering (cut polygons, bounding boxes, postal codes)</li> <li>Deduplication (coordinate-based with configurable radius)</li> <li>Support level tracking (LEVEL_1 through LEVEL_4)</li> <li>Sign tracking (lawn signs, sizes)</li> <li>Public map API (PII-filtered)</li> <li>Statistics dashboard (geocoding quality, provider distribution, confidence levels)</li> </ul>"},{"location":"v2/backend/modules/locations/#file-paths","title":"File Paths","text":"File Purpose <code>api/src/modules/map/locations/locations.routes.ts</code> 2 routers (admin + public) with 20 endpoints <code>api/src/modules/map/locations/locations.service.ts</code> Location business logic + geocoding + NAR import (1,100 lines) <code>api/src/modules/map/locations/locations.schemas.ts</code> Zod validation schemas <code>api/src/modules/map/locations/nar-import.service.ts</code> NAR import service (server-side streaming, legacy support) <code>api/src/modules/map/locations/nar-import.routes.ts</code> NAR import admin routes <code>api/src/modules/map/locations/bulk-geocode.routes.ts</code> Bulk geocoding queue routes <code>api/src/modules/map/locations/bulk-geocode.schemas.ts</code> Bulk geocoding schemas"},{"location":"v2/backend/modules/locations/#database-models","title":"Database Models","text":""},{"location":"v2/backend/modules/locations/#location","title":"Location","text":"<pre><code>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</code></pre>"},{"location":"v2/backend/modules/locations/#locationhistory","title":"LocationHistory","text":"<pre><code>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</code></pre> <p>History Tracking:</p> <ul> <li>All location changes recorded with before/after values</li> <li><code>CREATED</code> \u2014 Location created (manual or import)</li> <li><code>UPDATED</code> \u2014 Field changed</li> <li><code>GEOCODED</code> \u2014 Address geocoded (auto or bulk geocoding)</li> <li><code>MOVED_ON_MAP</code> \u2014 Lat/lng changed via map drag</li> <li><code>DELETED</code> \u2014 Location deleted</li> </ul>"},{"location":"v2/backend/modules/locations/#api-endpoints","title":"API Endpoints","text":""},{"location":"v2/backend/modules/locations/#admin-endpoints-authentication-required","title":"Admin Endpoints (Authentication Required)","text":"Method Path Description GET <code>/api/map/locations</code> List locations (paginated, filtered) GET <code>/api/map/locations/stats</code> Location statistics GET <code>/api/map/locations/export-csv</code> Export CSV download GET <code>/api/map/locations/all</code> All geocoded locations for map (admin, 5000 limit) GET <code>/api/map/locations/:id</code> Get single location GET <code>/api/map/locations/:id/history</code> Get location edit history POST <code>/api/map/locations</code> Create location (auto-geocodes if no lat/lng) POST <code>/api/map/locations/geocode</code> Geocode single address POST <code>/api/map/locations/geocode-missing</code> Geocode all ungeocoded locations POST <code>/api/map/locations/import-csv</code> Upload + import CSV (10MB limit) POST <code>/api/map/locations/import-bulk</code> Bulk import NAR or CSV (100MB limit, 5min timeout) POST <code>/api/map/locations/reverse-geocode</code> Reverse geocode lat/lng to address POST <code>/api/map/locations/bulk-delete</code> Delete multiple locations PUT <code>/api/map/locations/:id</code> Update location DELETE <code>/api/map/locations/:id</code> Delete location <p>Admin Roles: <code>SUPER_ADMIN</code>, <code>MAP_ADMIN</code></p>"},{"location":"v2/backend/modules/locations/#public-endpoints-no-authentication","title":"Public Endpoints (No Authentication)","text":"Method Path Description GET <code>/api/map/locations/public</code> 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":"<p>List locations with pagination, search, and filtering.</p> <p>Query Parameters:</p> 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: <code>high</code> (85+), <code>medium</code> (60-84), <code>low</code> (<60), <code>none</code> (0 or null) sortBy string No createdAt Sort field: <code>createdAt</code>, <code>address</code>, <code>supportLevel</code> sortOrder string No desc Sort order: <code>asc</code>, <code>desc</code> <p>Example Request:</p> <pre><code>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</code></pre> <p>Response (200 OK):</p> <pre><code>{\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</code></pre> <p>Search Logic:</p> <pre><code>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</code></pre> <p>Confidence Level Filtering:</p> <pre><code>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</code></pre>"},{"location":"v2/backend/modules/locations/#get-apimaplocationsstats","title":"GET /api/map/locations/stats","text":"<p>Get aggregate statistics for locations.</p> <p>Example Request:</p> <pre><code>curl -H \"Authorization: Bearer <token>\" \\\n \"http://api.cmlite.org/api/map/locations/stats\"\n</code></pre> <p>Response (200 OK):</p> <pre><code>{\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</code></pre> <p>Field Descriptions:</p> <ul> <li><code>total</code> \u2014 Total location count</li> <li><code>supportLevels</code> \u2014 Breakdown by support level</li> <li><code>signs</code> \u2014 Locations with <code>sign=true</code></li> <li><code>geocoded</code> \u2014 Locations with lat/lng</li> <li><code>ungeocoded</code> \u2014 Locations without lat/lng</li> <li><code>confidence.high</code> \u2014 Geocode confidence \u2265 85</li> <li><code>confidence.medium</code> \u2014 Geocode confidence 60-84</li> <li><code>confidence.low</code> \u2014 Geocode confidence < 60</li> <li><code>confidence.none</code> \u2014 No geocode confidence (0 or null)</li> <li><code>confidence.average</code> \u2014 Average geocode confidence (excludes 0/null)</li> <li><code>providers</code> \u2014 Breakdown by geocode provider</li> </ul>"},{"location":"v2/backend/modules/locations/#post-apimaplocations","title":"POST /api/map/locations","text":"<p>Create new location with automatic geocoding.</p> <p>Request Body:</p> <pre><code>{\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</code></pre> <p>Response (201 Created):</p> <p>Returns created location object.</p> <p>Auto-Geocoding:</p> <p>If <code>address</code> provided and no <code>latitude</code>/<code>longitude</code>, automatically geocodes:</p> <pre><code>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</code></pre> <p>History Tracking:</p> <p>Creates <code>LocationHistory</code> record with action <code>GEOCODED</code> (if geocoded) or <code>CREATED</code> (if manual coordinates).</p>"},{"location":"v2/backend/modules/locations/#put-apimaplocationsid","title":"PUT /api/map/locations/:id","text":"<p>Update location. Re-geocodes if address changes without explicit lat/lng.</p> <p>Request Body (Partial):</p> <pre><code>{\n \"address\": \"456 Oak St, Toronto, ON\",\n \"supportLevel\": \"LEVEL_2\"\n}\n</code></pre> <p>Response (200 OK):</p> <p>Returns updated location object.</p> <p>Smart Geocoding:</p> <ul> <li>If address changes and no explicit lat/lng provided: re-geocode automatically</li> <li>If lat/lng provided: use provided coordinates (manual override)</li> </ul> <p>History Tracking:</p> <p>Records field changes with before/after values:</p> <pre><code>// 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</code></pre>"},{"location":"v2/backend/modules/locations/#post-apimaplocationsimport-csv","title":"POST /api/map/locations/import-csv","text":"<p>Upload and import CSV file with flexible column mapping.</p> <p>Multipart Form Data:</p> <ul> <li><code>file</code> (required): CSV file (max 10MB)</li> </ul> <p>Supported Column Names (Case-Insensitive):</p> Field Column Names address <code>address</code>, <code>street</code>, <code>street address</code> firstName <code>first name</code>, <code>firstname</code>, <code>first</code> lastName <code>last name</code>, <code>lastname</code>, <code>last</code> email <code>email</code>, <code>e-mail</code> phone <code>phone</code>, <code>telephone</code>, <code>tel</code>, <code>phone number</code> unitNumber <code>unit</code>, <code>unit number</code>, <code>apt</code>, <code>apartment</code>, <code>suite</code> supportLevel <code>support level</code>, <code>supportlevel</code>, <code>support</code>, <code>level</code> sign <code>sign</code>, <code>lawn sign</code> signSize <code>sign size</code>, <code>signsize</code> notes <code>notes</code>, <code>note</code>, <code>comments</code> latitude <code>latitude</code>, <code>lat</code> longitude <code>longitude</code>, <code>lng</code>, <code>lon</code> <p>Example CSV:</p> <pre><code>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</code></pre> <p>Example Request:</p> <pre><code>curl -X POST -H \"Authorization: Bearer <token>\" \\\n -F \"file=@locations.csv\" \\\n \"http://api.cmlite.org/api/map/locations/import-csv\"\n</code></pre> <p>Response (200 OK):</p> <pre><code>{\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</code></pre> <p>Field Descriptions:</p> <ul> <li><code>total</code> \u2014 Total rows in CSV</li> <li><code>success</code> \u2014 Successfully created locations</li> <li><code>warnings</code> \u2014 Created but geocoding failed (no lat/lng)</li> <li><code>failed</code> \u2014 Failed to create (validation errors)</li> <li><code>errors</code> \u2014 First 50 error messages (row numbers 1-indexed)</li> </ul> <p>Geocoding:</p> <ul> <li>If CSV has <code>latitude</code>/<code>longitude</code> columns: uses provided coordinates</li> <li>Otherwise: auto-geocodes each address (slow for large files, consider NAR import for bulk)</li> </ul>"},{"location":"v2/backend/modules/locations/#post-apimaplocationsimport-bulk","title":"POST /api/map/locations/import-bulk","text":"<p>Bulk import NAR (National Address Register) or standard CSV with advanced filtering.</p> <p>Multipart Form Data:</p> <ul> <li><code>file</code> (required): CSV file (max 100MB)</li> <li><code>format</code> (required): <code>nar</code> or <code>standard</code></li> <li><code>filterType</code> (optional): <code>none</code>, <code>cut</code>, <code>mapArea</code>, <code>city</code>, <code>province</code></li> <li><code>cutId</code> (optional): Cut ID for <code>filterType=cut</code></li> <li><code>filterCity</code> (optional): City name for <code>filterType=city</code></li> <li><code>filterProvince</code> (optional): Province code for <code>filterType=province</code> (e.g., <code>ON</code>, <code>BC</code>)</li> <li><code>residentialOnly</code> (optional, default: false): Skip non-residential buildings (NAR only)</li> <li><code>deduplicateRadius</code> (optional, default: 5): Coordinate deduplication radius in meters</li> <li><code>skipGeocoding</code> (optional, default: true): Skip geocoding (NAR files have coordinates)</li> <li><code>batchSize</code> (optional, default: 1000): Database batch insert size</li> </ul> <p>Request Timeout: 5 minutes (extended for large files)</p> <p>Example Request (NAR Import with Cut Filter):</p> <pre><code>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</code></pre> <p>Response (200 OK):</p> <pre><code>{\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</code></pre> <p>NAR Format Support:</p> <p>2025 NAR Format (Recommended):</p> <ul> <li>Address File Columns: <code>CIVIC_NO</code>, <code>CIVIC_NO_SUFFIX</code>, <code>OFFICIAL_STREET_NAME</code>, <code>OFFICIAL_STREET_TYPE</code>, <code>OFFICIAL_STREET_DIR</code>, <code>APT_NO_LABEL</code>, <code>BG_X</code>, <code>BG_Y</code>, <code>MAIL_MUN_NAME</code>, <code>MAIL_PROV_ABVN</code>, <code>MAIL_POSTAL_CODE</code>, <code>FED_ENG_NAME</code>, <code>BU_USE</code></li> <li>Location File Columns: <code>BG_LATITUDE</code>, <code>BG_LONGITUDE</code> (WGS84), <code>LOC_GUID</code></li> <li>Coordinate Systems:</li> <li><code>BG_X</code>/<code>BG_Y</code> \u2014 EPSG:3347 Lambert Conformal Conic (converted to WGS84)</li> <li><code>BG_LATITUDE</code>/<code>BG_LONGITUDE</code> \u2014 WGS84 (used directly)</li> </ul> <p>Legacy NAR Format (Backward Compatible):</p> <ul> <li>Columns: <code>STR_NBR</code>, <code>STR_NME</code>, <code>STR_TYP</code>, <code>STR_DIR</code>, <code>LAT</code>, <code>LNG</code>, <code>MUN_NME</code>, <code>PRV_NME</code></li> </ul> <p>Auto-Detection:</p> <p>If 3+ NAR-specific columns detected, automatically treats as NAR format.</p> <p>Lambert Projection Conversion:</p> <pre><code>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</code></pre> <p>Filtering Options:</p> <ol> <li>Cut Filter (<code>filterType=cut</code>):</li> <li>Only imports locations inside specified cut polygon</li> <li> <p>Uses point-in-polygon ray-casting algorithm</p> </li> <li> <p>Map Area Filter (<code>filterType=mapArea</code>):</p> </li> <li>Imports locations visible on current map view</li> <li> <p>Calculates bounding box from MapSettings (center, zoom)</p> </li> <li> <p>City Filter (<code>filterType=city</code>):</p> </li> <li> <p>Imports locations matching city name (case-insensitive)</p> </li> <li> <p>Province Filter (<code>filterType=province</code>):</p> </li> <li>Imports locations matching province code (e.g., <code>ON</code>, <code>BC</code>)</li> </ol> <p>Deduplication:</p> <p>Prevents duplicate locations at same coordinates:</p> <pre><code>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</code></pre> <p>Batch Processing:</p> <p>Inserts locations in batches (default 1000) for performance:</p> <pre><code>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</code></pre>"},{"location":"v2/backend/modules/locations/#get-apimaplocationsexport-csv","title":"GET /api/map/locations/export-csv","text":"<p>Export locations as CSV download.</p> <p>Example Request:</p> <pre><code>curl -H \"Authorization: Bearer <token>\" \\\n \"http://api.cmlite.org/api/map/locations/export-csv\" \\\n -o locations.csv\n</code></pre> <p>Response (200 OK):</p> <p>CSV file with headers:</p> <pre><code>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</code></pre>"},{"location":"v2/backend/modules/locations/#post-apimaplocationsreverse-geocode","title":"POST /api/map/locations/reverse-geocode","text":"<p>Reverse geocode coordinates to address.</p> <p>Request Body:</p> <pre><code>{\n \"latitude\": 43.6532,\n \"longitude\": -79.3832\n}\n</code></pre> <p>Response (200 OK):</p> <pre><code>{\n \"address\": \"123 Main St, Toronto, ON M5H 2N2, Canada\",\n \"provider\": \"NOMINATIM\",\n \"confidence\": 85\n}\n</code></pre> <p>Use Cases:</p> <ul> <li>Click-to-add location on map (get address from coordinates)</li> <li>Move location on map (update address after drag)</li> <li>Verify coordinates match expected address</li> </ul>"},{"location":"v2/backend/modules/locations/#get-apimaplocationsall","title":"GET /api/map/locations/all","text":"<p>Get all geocoded locations for admin map view.</p> <p>Query Parameters:</p> Parameter Type Description minLat number Minimum latitude (bounding box) maxLat number Maximum latitude minLng number Minimum longitude maxLng number Maximum longitude <p>Example Request:</p> <pre><code># 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</code></pre> <p>Response (200 OK):</p> <p>Returns array of location objects (max 5000).</p> <p>Safety Limit:</p> <p>If result hits 5000 locations, adds header <code>X-Location-Limit-Hit: true</code> to warn client.</p>"},{"location":"v2/backend/modules/locations/#get-apimaplocationsidhistory","title":"GET /api/map/locations/:id/history","text":"<p>Get location edit history with audit trail.</p> <p>Query Parameters:</p> <ul> <li><code>page</code> (optional, default: 1): Page number</li> <li><code>limit</code> (optional, default: 20): Results per page</li> </ul> <p>Example Request:</p> <pre><code>curl -H \"Authorization: Bearer <token>\" \\\n \"http://api.cmlite.org/api/map/locations/clx1234567890/history?page=1&limit=20\"\n</code></pre> <p>Response (200 OK):</p> <pre><code>{\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</code></pre> <p>History Actions:</p> <ul> <li><code>CREATED</code> \u2014 Location created</li> <li><code>UPDATED</code> \u2014 Field changed (address, name, email, etc.)</li> <li><code>GEOCODED</code> \u2014 Auto-geocoded (address \u2192 lat/lng)</li> <li><code>MOVED_ON_MAP</code> \u2014 Coordinates changed via map drag</li> <li><code>DELETED</code> \u2014 Location deleted (orphaned history records)</li> </ul>"},{"location":"v2/backend/modules/locations/#public-endpoint-details","title":"Public Endpoint Details","text":""},{"location":"v2/backend/modules/locations/#get-apimaplocationspublic","title":"GET /api/map/locations/public","text":"<p>Get locations for public map (PII-filtered).</p> <p>Query Parameters:</p> <ul> <li><code>minLat</code>, <code>maxLat</code>, <code>minLng</code>, <code>maxLng</code> (optional): Bounding box</li> </ul> <p>Example Request:</p> <pre><code>curl \"http://api.cmlite.org/api/public/map/locations?minLat=43.6&maxLat=43.7&minLng=-79.4&maxLng=-79.3\"\n</code></pre> <p>Response (200 OK):</p> <pre><code>[\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</code></pre> <p>PII Filtering:</p> <p>Only returns non-sensitive fields:</p> <ul> <li>Included: <code>id</code>, <code>latitude</code>, <code>longitude</code>, <code>supportLevel</code>, <code>sign</code>, <code>signSize</code>, <code>unitNumber</code>, <code>address</code></li> <li>Excluded: <code>firstName</code>, <code>lastName</code>, <code>email</code>, <code>phone</code>, <code>notes</code>, <code>buildingNotes</code>, <code>geocodeConfidence</code>, <code>geocodeProvider</code>, <code>createdByUserId</code>, <code>postalCode</code>, <code>province</code>, <code>federalDistrict</code>, <code>buildingUse</code></li> </ul>"},{"location":"v2/backend/modules/locations/#service-functions","title":"Service Functions","text":""},{"location":"v2/backend/modules/locations/#locationsservicecreatedata-userid","title":"locationsService.create(data, userId)","text":"<p>Create location with auto-geocoding.</p> <p>Auto-Geocoding Logic:</p> <pre><code>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</code></pre> <p>History Recording:</p> <p>Creates history record in transaction:</p> <pre><code>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</code></pre>"},{"location":"v2/backend/modules/locations/#locationsserviceupdateid-data-userid","title":"locationsService.update(id, data, userId)","text":"<p>Update location with smart geocoding and history tracking.</p> <p>Smart Geocoding:</p> <ul> <li>If address changes and no explicit lat/lng: re-geocode</li> <li>If lat/lng provided: use provided coordinates (manual override)</li> </ul> <p>Action Detection:</p> <pre><code>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</code></pre> <p>Change Tracking:</p> <pre><code>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</code></pre>"},{"location":"v2/backend/modules/locations/#locationsserviceimportfromcsvbuffer-userid","title":"locationsService.importFromCsv(buffer, userId)","text":"<p>Import CSV with flexible column mapping.</p> <p>Column Mapping:</p> <pre><code>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</code></pre> <p>Processing:</p> <ol> <li>Parse CSV with <code>csv-parse</code> library</li> <li>Detect column mapping from headers</li> <li>For each row:</li> <li>Validate required fields (address)</li> <li>Parse support level, sign boolean</li> <li>Use provided lat/lng or geocode address</li> <li>Create location in database</li> <li>Return summary statistics</li> </ol>"},{"location":"v2/backend/modules/locations/#locationsserviceimportbulkbuffer-userid-options-filters","title":"locationsService.importBulk(buffer, userId, options, filters)","text":"<p>Bulk import NAR or standard CSV with advanced filtering.</p> <p>NAR Format Detection:</p> <pre><code>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</code></pre> <p>3-Phase Processing:</p> <p>Phase 1: Parse & Filter</p> <pre><code>// 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</code></pre> <p>Phase 2: Batch Geocode</p> <pre><code>// 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</code></pre> <p>Phase 3: Create Records</p> <pre><code>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</code></pre>"},{"location":"v2/backend/modules/locations/#locationsserviceexporttocsvfilters","title":"locationsService.exportToCsv(filters?)","text":"<p>Export locations as CSV.</p> <p>CSV Generation:</p> <pre><code>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</code></pre>"},{"location":"v2/backend/modules/locations/#validation-schemas","title":"Validation Schemas","text":""},{"location":"v2/backend/modules/locations/#create-location-schema","title":"Create Location Schema","text":"<pre><code>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</code></pre>"},{"location":"v2/backend/modules/locations/#bulk-import-schema","title":"Bulk Import Schema","text":"<pre><code>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</code></pre>"},{"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":"<pre><code>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</code></pre>"},{"location":"v2/backend/modules/locations/#admin-import-nar-file-with-cut-filter","title":"Admin: Import NAR File with Cut Filter","text":"<pre><code>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</code></pre>"},{"location":"v2/backend/modules/locations/#admin-export-locations","title":"Admin: Export Locations","text":"<pre><code>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</code></pre>"},{"location":"v2/backend/modules/locations/#frontend-integration","title":"Frontend Integration","text":"<p>The LocationsPage component (<code>admin/src/pages/LocationsPage.tsx</code>) provides:</p> <ul> <li>Location table with pagination (20 results/page)</li> <li>Search (address, name, email)</li> <li>Filters (support level, sign, confidence level)</li> <li>Sorting (createdAt, address, supportLevel)</li> <li>Statistics dashboard (total, support levels, signs, geocoded, confidence breakdown, provider distribution)</li> <li>Create location modal (form with auto-geocoding preview)</li> <li>Edit location modal (pre-populated form)</li> <li>Delete location action</li> <li>Bulk delete (select multiple rows)</li> <li>CSV import (10MB limit)</li> <li>NAR bulk import (100MB limit, cut/city/province filters)</li> <li>CSV export (download button)</li> <li>Geocode missing button (batch geocodes all ungeocoded)</li> <li>Location history drawer (audit trail with user, action, field changes)</li> <li>Map integration (shows all geocoded locations, click-to-add, drag-to-move)</li> </ul> <p>State Management:</p> <pre><code>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</code></pre>"},{"location":"v2/backend/modules/locations/#performance-considerations","title":"Performance Considerations","text":"<p>Batch Processing:</p> <ul> <li>NAR import uses 1000-record batches (configurable)</li> <li>Reduces transaction overhead</li> <li>Improves import speed (10,000+ locations/minute)</li> </ul> <p>Deduplication:</p> <ul> <li>Coordinate-based (5 decimal places = ~1.1m precision)</li> <li>In-memory Set for fast lookups</li> <li>Prevents duplicate imports within same file</li> </ul> <p>Indexing:</p> <ul> <li><code>@@index([latitude, longitude])</code> \u2014 Fast map bounds queries</li> <li><code>@@index([supportLevel])</code> \u2014 Fast filtering by support level</li> <li><code>@@index([sign])</code> \u2014 Fast sign filtering</li> <li><code>@@index([geocodeConfidence])</code> \u2014 Fast confidence filtering</li> </ul> <p>Safety Limits:</p> <ul> <li>Map queries limited to 5000 locations</li> <li>CSV import limited to 10MB</li> <li>Bulk import limited to 100MB (5-minute timeout)</li> <li>Bulk import warning header when limit hit</li> </ul> <p>Geocoding:</p> <ul> <li>Auto-geocodes on create/update (individual addresses)</li> <li>Batch geocoding for bulk imports (parallel processing)</li> <li>Uses BullMQ queue for background geocoding (separate service)</li> </ul>"},{"location":"v2/backend/modules/locations/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/backend/modules/locations/#issue-csv-import-fails-with-invalid-csv-file-format","title":"Issue: CSV import fails with \"Invalid CSV file format\"","text":"<p>Cause: CSV not UTF-8 encoded or has malformed rows</p> <p>Solution:</p> <ul> <li>Save CSV as UTF-8 in Excel/LibreOffice</li> <li>Ensure no missing quote delimiters</li> <li>Remove empty rows at end of file</li> </ul>"},{"location":"v2/backend/modules/locations/#issue-nar-import-skips-all-records-skippedoutofbounds-total","title":"Issue: NAR import skips all records (skippedOutOfBounds = total)","text":"<p>Cause: Cut/city/province filter doesn't match any records</p> <p>Solution:</p> <ul> <li>Verify cut ID is correct</li> <li>Check city/province spelling matches NAR data (case-insensitive)</li> <li>Try without filters first to verify file format</li> </ul>"},{"location":"v2/backend/modules/locations/#issue-geocoding-confidence-is-low-60-for-many-locations","title":"Issue: Geocoding confidence is low (<60) for many locations","text":"<p>Cause: Incomplete addresses or geocoding provider limitations</p> <p>Solution:</p> <ul> <li>Use NAR import (has pre-geocoded coordinates)</li> <li>Add city/province to addresses</li> <li>Try different geocoding provider (see settings)</li> <li>Use \"Geocode Missing\" button to retry with fallback providers</li> </ul>"},{"location":"v2/backend/modules/locations/#issue-bulk-import-times-out-after-5-minutes","title":"Issue: Bulk import times out after 5 minutes","text":"<p>Cause: File too large or too many locations to geocode</p> <p>Solution:</p> <ul> <li>Set <code>skipGeocoding=true</code> for NAR imports (coordinates included)</li> <li>Split large files into smaller batches</li> <li>Use cut filter to reduce import size</li> <li>Increase <code>batchSize</code> parameter (1000 \u2192 2000)</li> </ul>"},{"location":"v2/backend/modules/locations/#related-documentation","title":"Related Documentation","text":"<ul> <li>Geocoding Service - Multi-provider geocoding</li> <li>Cuts Module - Polygon filtering</li> <li>Spatial Utils - Point-in-polygon, bounds calculation</li> <li>Frontend: LocationsPage - Location management UI</li> <li>Frontend: Public Map Page - Public location map</li> <li>API Reference: Locations - Complete endpoint reference</li> <li>Feature: Location Management - Location management feature guide</li> <li>Feature: NAR Import - NAR bulk import guide</li> </ul>"},{"location":"v2/backend/modules/media/","title":"Media Module (Fastify Video Library API)","text":""},{"location":"v2/backend/modules/media/#overview","title":"Overview","text":"<p>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.</p> <p>Key Features:</p> <ul> <li>Dual API architecture:</li> <li>Main Express API (port 4000) \u2014 Prisma ORM</li> <li>Media Fastify API (port 4100) \u2014 Drizzle ORM</li> <li>Shared PostgreSQL 16 database</li> <li>Video library management:</li> <li>Directory-based organization (studios, gifs, private, inbox, curated, etc.)</li> <li>Metadata tracking (duration, quality, orientation, file size, dimensions)</li> <li>Thumbnail generation and storage</li> <li>File hash-based deduplication</li> <li>Public gallery system:</li> <li>Category-based organization</li> <li>Engagement tracking (views, upvotes, comments, watch time)</li> <li>Lock/unlock system for controlling public visibility</li> <li>Session-based upvoting (no auth required)</li> <li>Reaction system:</li> <li>6 emoji reactions (\ud83d\udc4d like, \u2764\ufe0f love, \ud83d\ude02 laugh, \ud83d\ude2e wow, \ud83d\ude22 sad, \ud83d\ude20 angry)</li> <li>Timestamped reactions (mark specific moments in videos)</li> <li>User-based tracking (authenticated users)</li> <li>Job queue:</li> <li>Video processing job management</li> <li>Resource category allocation (GPU AI, GPU encode, CPU)</li> <li>Queue position tracking with VRAM requirements</li> <li>Pipeline integration for multi-step processing</li> <li>Compilation management:</li> <li>Multi-video compilation tracking</li> <li>Settings preservation</li> <li>Feature flag: <code>ENABLE_MEDIA_FEATURES=true</code> (opt-in)</li> </ul>"},{"location":"v2/backend/modules/media/#file-paths","title":"File Paths","text":"File Purpose <code>api/src/media-server.ts</code> Fastify server entry point (port 4100) <code>api/src/modules/media/db/schema.ts</code> Drizzle schema (15+ tables, 1,400+ lines) <code>api/src/modules/media/routes/videos.routes.ts</code> Video CRUD routes (99 lines) <code>api/src/modules/media/routes/public-media.routes.ts</code> Public gallery routes (12,852 lines) <code>api/src/modules/media/routes/reactions.routes.ts</code> Reaction routes (135 lines) <code>api/src/modules/media/routes/comments.routes.ts</code> Comment routes (4,827 lines) <code>api/src/modules/media/middleware/auth.ts</code> Fastify auth middleware (JWT verification) <code>api/src/modules/media/types/enums.ts</code> 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":"<pre><code>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</code></pre> <p>Key Features:</p> <ul> <li>Unique path constraint \u2014 Prevents duplicate entries</li> <li>File hash \u2014 Enables deduplication based on content</li> <li>Fingerprint index \u2014 Fast duplicate detection (duration + fileSize + width + height)</li> <li>Directory type \u2014 Efficient filtering by category</li> <li>Historical stats \u2014 Preserves engagement metrics when moving from public gallery</li> <li>Standardization tracking \u2014 Tracks original filename before renaming</li> </ul>"},{"location":"v2/backend/modules/media/#public-media-table","title":"Public Media Table","text":"<pre><code>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</code></pre> <p>Key Features:</p> <ul> <li>Denormalized counters \u2014 Fast sorting by popularity (no joins)</li> <li>Lock system \u2014 Admin can lock videos to prevent public access</li> <li>Category organization \u2014 Flexible categorization system</li> <li>Performance indexes \u2014 Optimized for sorting by views/upvotes</li> </ul>"},{"location":"v2/backend/modules/media/#upvotes-table","title":"Upvotes Table","text":"<pre><code>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</code></pre> <p>Key Features:</p> <ul> <li>Session-based \u2014 No authentication required (anonymous upvoting)</li> <li>Unique constraint \u2014 One upvote per session per media item</li> <li>Denormalized \u2014 upvoteCount in publicMedia table updated via trigger or application logic</li> </ul>"},{"location":"v2/backend/modules/media/#video-reactions-table","title":"Video Reactions Table","text":"<pre><code>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</code></pre> <p>Reaction Emojis:</p> Type Emoji Label <code>like</code> \ud83d\udc4d Like <code>love</code> \u2764\ufe0f Love <code>laugh</code> \ud83d\ude02 Laugh <code>wow</code> \ud83d\ude2e Wow <code>sad</code> \ud83d\ude22 Sad <code>angry</code> \ud83d\ude20 Angry <p>Key Features:</p> <ul> <li>Timestamped reactions \u2014 Mark specific moments in videos</li> <li>User-based \u2014 Requires authentication</li> <li>Timeline visualization \u2014 Can show reaction heatmap across video timeline</li> </ul>"},{"location":"v2/backend/modules/media/#jobs-table","title":"Jobs Table","text":"<pre><code>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</code></pre> <p>Job Types:</p> <ul> <li><code>compilation</code> \u2014 Multi-video compilation</li> <li><code>scan</code>, <code>public_scan</code> \u2014 Video library scanning</li> <li><code>organize</code>, <code>organize_studio</code> \u2014 Automatic organization</li> <li><code>reencode_streaming</code> \u2014 Transcode for web streaming</li> <li><code>compile_random</code>, <code>compile_quad</code>, <code>compile_quad_horizontal</code>, etc. \u2014 Compilation variants</li> <li><code>generate_gif</code>, <code>fetch</code>, <code>digest</code>, <code>clip_generate</code>, <code>highlight_generate</code> \u2014 Content generation</li> <li><code>tag_generation</code>, <code>scene_extract</code>, <code>clip_extract_only</code>, <code>auto_organize_publish</code> \u2014 AI-powered tasks</li> </ul> <p>Resource Categories:</p> <ul> <li><code>gpu_ai</code> \u2014 AI/ML tasks (scene detection, tagging, etc.) \u2014 High VRAM</li> <li><code>gpu_encode</code> \u2014 Video encoding/transcoding \u2014 Medium VRAM</li> <li><code>cpu</code> \u2014 General processing \u2014 No GPU required</li> </ul>"},{"location":"v2/backend/modules/media/#compilations-table","title":"Compilations Table","text":"<pre><code>export const compilations = pgTable('compilations', {\n id: serial('id').primaryKey(),\n filename: text('filename').notNull(),\n path: text('path'),\n durationSeconds: integer('duration_seconds'),\n videoIds: jsonb('video_ids').$type<number[]>(),\n settings: jsonb('settings').$type<Record<string, unknown>>(),\n createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),\n});\n</code></pre> <p>Key Features:</p> <ul> <li>Multi-video tracking \u2014 Stores array of source video IDs</li> <li>Settings preservation \u2014 Stores compilation parameters (layout, transitions, etc.)</li> </ul>"},{"location":"v2/backend/modules/media/#api-endpoints","title":"API Endpoints","text":""},{"location":"v2/backend/modules/media/#admin-endpoints-videos","title":"Admin Endpoints (Videos)","text":"Method Path Auth Description GET <code>/api/videos</code> Admin roles List videos with pagination GET <code>/api/videos/:id</code> Admin roles Get single video GET <code>/api/videos/health</code> None Health check <p>Admin Roles: Requires admin role via Fastify auth middleware</p>"},{"location":"v2/backend/modules/media/#public-media-endpoints","title":"Public Media Endpoints","text":"Method Path Auth Description GET <code>/api/media/public</code> None List shared media (paginated, filterable, sorted) GET <code>/api/media/public/:id</code> None Get single media + increment view count POST <code>/api/media/public/:id/upvote</code> None Upvote media (session-based) DELETE <code>/api/media/public/:id/upvote</code> None Remove upvote POST <code>/api/media/public/:id/finish</code> None Mark video as finished POST <code>/api/media/public/:id/watch-time</code> None Track watch time"},{"location":"v2/backend/modules/media/#reaction-endpoints","title":"Reaction Endpoints","text":"Method Path Auth Description POST <code>/api/reactions</code> Required Add reaction to video GET <code>/api/reactions</code> None Get reactions (filterable by mediaId/userId) GET <code>/api/reactions/config</code> None Get available reaction types"},{"location":"v2/backend/modules/media/#comment-endpoints","title":"Comment Endpoints","text":"Method Path Auth Description POST <code>/api/media/comments</code> Optional Add comment (auth optional, session-based) GET <code>/api/media/comments</code> 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":"<p>List videos with pagination and search (admin only).</p> <p>Query Parameters:</p> Parameter Type Default Description limit number 50 Results per page (max 100) offset number 0 Skip N results search string - Search title (case-insensitive) <p>Example Request:</p> <pre><code>curl -H \"Authorization: Bearer <token>\" \\\n \"http://localhost:4100/api/videos?limit=20&offset=0&search=demo\"\n</code></pre> <p>Response (200 OK):</p> <pre><code>{\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</code></pre>"},{"location":"v2/backend/modules/media/#get-apimediapublic","title":"GET /api/media/public","text":"<p>List shared media with pagination, filtering, and sorting (no auth required).</p> <p>Query Parameters:</p> Parameter Type Default Description category string - Filter by category search string - Search filename/path sort enum <code>recent</code> Sort: <code>recent</code>, <code>popular</code>, <code>most_viewed</code> orientation string - Filter by orientation limit number 24 Results per page (max 100) offset number 0 Skip N results <p>Example Request:</p> <pre><code>curl \"http://localhost:4100/api/media/public?category=highlights&sort=popular&limit=12\"\n</code></pre> <p>Response (200 OK):</p> <pre><code>{\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</code></pre> <p>Sort Modes:</p> <pre><code>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</code></pre>"},{"location":"v2/backend/modules/media/#get-apimediapublicid","title":"GET /api/media/public/:id","text":"<p>Get single media details and increment view count (no auth required).</p> <p>Path Parameters:</p> <ul> <li><code>id</code> (number): Media ID</li> </ul> <p>Example Request:</p> <pre><code>curl \"http://localhost:4100/api/media/public/456\"\n</code></pre> <p>Response (200 OK):</p> <pre><code>{\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</code></pre> <p>Side Effect:</p> <p>View count is incremented fire-and-forget (does not block response):</p> <pre><code>// 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</code></pre>"},{"location":"v2/backend/modules/media/#post-apimediapublicidupvote","title":"POST /api/media/public/:id/upvote","text":"<p>Upvote media (session-based, no auth required).</p> <p>Path Parameters:</p> <ul> <li><code>id</code> (number): Media ID</li> </ul> <p>Request Body:</p> <pre><code>{\n \"sessionId\": \"sess_abc123def456\"\n}\n</code></pre> <p>Response (200 OK):</p> <pre><code>{\n \"success\": true,\n \"upvoted\": true,\n \"upvoteCount\": 90\n}\n</code></pre> <p>Behavior:</p> <ul> <li>Idempotent \u2014 If already upvoted, returns existing upvote</li> <li>Denormalized counter \u2014 Updates <code>publicMedia.upvoteCount</code> atomically</li> <li>Session-based \u2014 No authentication required</li> </ul> <p>Duplicate Prevention:</p> <pre><code>// 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</code></pre>"},{"location":"v2/backend/modules/media/#delete-apimediapublicidupvote","title":"DELETE /api/media/public/:id/upvote","text":"<p>Remove upvote (session-based).</p> <p>Path Parameters:</p> <ul> <li><code>id</code> (number): Media ID</li> </ul> <p>Query Parameters:</p> <ul> <li><code>sessionId</code> (string): Session ID</li> </ul> <p>Response (200 OK):</p> <pre><code>{\n \"success\": true,\n \"upvoted\": false,\n \"upvoteCount\": 89\n}\n</code></pre>"},{"location":"v2/backend/modules/media/#post-apireactions","title":"POST /api/reactions","text":"<p>Add reaction to video (authenticated users only).</p> <p>Request Body:</p> <pre><code>{\n \"mediaId\": 456,\n \"reactionType\": \"love\",\n \"videoTimestamp\": 27\n}\n</code></pre> <p>Response (200 OK):</p> <pre><code>{\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</code></pre> <p>Validation:</p> <pre><code>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</code></pre> <p>Time Formatting:</p> <pre><code>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</code></pre>"},{"location":"v2/backend/modules/media/#get-apireactions","title":"GET /api/reactions","text":"<p>Get reactions (filterable by mediaId/userId).</p> <p>Query Parameters:</p> Parameter Type Description mediaId number Filter by media ID userId string Filter by user ID limit number Results per page (default 50) <p>Example Request:</p> <pre><code>curl \"http://localhost:4100/api/reactions?mediaId=456&limit=20\"\n</code></pre> <p>Response (200 OK):</p> <pre><code>{\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</code></pre>"},{"location":"v2/backend/modules/media/#get-apireactionsconfig","title":"GET /api/reactions/config","text":"<p>Get available reaction types.</p> <p>Example Request:</p> <pre><code>curl \"http://localhost:4100/api/reactions/config\"\n</code></pre> <p>Response (200 OK):</p> <pre><code>{\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</code></pre>"},{"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 <code>authenticate</code>, <code>requireRole</code> <code>authenticate</code>, <code>requireAdminRole</code>, <code>optionalAuth</code> Error Handling <code>AppError</code> class + error handler middleware <code>fastify.httpErrors</code> + decorators Route Registration <code>router.get(...)</code> <code>fastify.register(routes, { prefix })</code> Request Handler <code>(req, res, next) => {}</code> <code>async (request, reply) => {}</code> Database Client <code>import { prisma }</code> <code>import { db }</code> Query Builder Prisma fluent API Drizzle query builder"},{"location":"v2/backend/modules/media/#code-pattern-comparison","title":"Code Pattern Comparison","text":"<p>Express (Prisma):</p> <pre><code>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</code></pre> <p>Fastify (Drizzle):</p> <pre><code>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</code></pre>"},{"location":"v2/backend/modules/media/#frontend-integration","title":"Frontend Integration","text":"<p>The Media module integrates with multiple frontend pages:</p>"},{"location":"v2/backend/modules/media/#admin-pages","title":"Admin Pages","text":"<ul> <li>LibraryPage (<code>admin/src/pages/media/LibraryPage.tsx</code>)</li> <li>Video grid with thumbnails</li> <li>Filter by directory type</li> <li>Search by filename</li> <li> <p>Bulk operations (lock, unlock, delete)</p> </li> <li> <p>SharedMediaPage (<code>admin/src/pages/media/SharedMediaPage.tsx</code>)</p> </li> <li>Public gallery admin</li> <li>Category management</li> <li>Lock/unlock controls</li> <li> <p>Engagement metrics display</p> </li> <li> <p>MediaJobsPage (<code>admin/src/pages/media/MediaJobsPage.tsx</code>)</p> </li> <li>Job queue monitoring</li> <li>Job status tracking (pending, queued, running, completed, failed)</li> <li>Progress visualization</li> <li>Resource category filtering</li> </ul>"},{"location":"v2/backend/modules/media/#public-pages","title":"Public Pages","text":"<ul> <li>MediaGalleryPage (<code>admin/src/pages/public/MediaGalleryPage.tsx</code>)</li> <li>Public video gallery</li> <li>Category filtering</li> <li>Sort by recent/popular/most viewed</li> <li>Upvote functionality (session-based)</li> <li> <p>View count display</p> </li> <li> <p>MediaViewerPage (<code>admin/src/pages/public/MediaViewerPage.tsx</code>)</p> </li> <li>Video player with reactions</li> <li>Timestamped reactions overlay</li> <li>Comment section</li> <li>Related videos</li> <li>Share functionality</li> </ul> <p>State Management:</p> <pre><code>// 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</code></pre>"},{"location":"v2/backend/modules/media/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/backend/modules/media/#denormalized-counters","title":"Denormalized Counters","text":"<p>The <code>publicMedia</code> table uses denormalized counters for engagement metrics:</p> <pre><code>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</code></pre> <p>Pros:</p> <ul> <li>Fast sorting \u2014 No joins or aggregations needed</li> <li>Instant popularity ranking \u2014 Direct sorting on indexed columns</li> <li>Simple queries \u2014 No complex GROUP BY clauses</li> </ul> <p>Cons:</p> <ul> <li>Consistency risk \u2014 Counters can drift if transactions fail</li> <li>Update overhead \u2014 Must update counter on every upvote/view</li> </ul> <p>Mitigation:</p> <ul> <li>Use atomic updates: <code>sql\\</code>${publicMedia.viewCount} + 1``</li> <li>Run periodic reconciliation job to fix drift</li> </ul>"},{"location":"v2/backend/modules/media/#fire-and-forget-view-tracking","title":"Fire-and-Forget View Tracking","text":"<p>View count increments are fire-and-forget to avoid blocking response:</p> <pre><code>// 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</code></pre> <p>Trade-off:</p> <ul> <li>Faster response \u2014 User doesn't wait for view count update</li> <li>Eventual consistency \u2014 View count may be slightly behind</li> </ul>"},{"location":"v2/backend/modules/media/#fingerprint-based-deduplication","title":"Fingerprint-Based Deduplication","text":"<p>The <code>videos</code> table includes a composite index for fast duplicate detection:</p> <pre><code>fingerprintIdx: index('idx_videos_fingerprint').on(\n table.durationSeconds, table.fileSize, table.width, table.height\n),\n</code></pre> <p>Usage:</p> <pre><code>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</code></pre> <p>Why Fingerprint Index:</p> <ul> <li>Fast pre-filter \u2014 Index lookup narrows candidates</li> <li>File hash check \u2014 Confirms exact duplicate (expensive, only on candidates)</li> <li>Two-stage approach \u2014 Balances speed and accuracy</li> </ul>"},{"location":"v2/backend/modules/media/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/backend/modules/media/#media-api-not-starting","title":"Media API Not Starting","text":"<p>Problem:</p> <p>Docker logs show \"Media API server closed\" immediately.</p> <p>Diagnosis:</p> <p>Check env vars:</p> <pre><code>docker compose exec api printenv | grep MEDIA\n</code></pre> <p>Required vars:</p> <pre><code>MEDIA_API_PORT=4100\nENABLE_MEDIA_FEATURES=true\nMAX_UPLOAD_SIZE_GB=10\n</code></pre> <p>Solution:</p> <ul> <li>Verify <code>ENABLE_MEDIA_FEATURES=true</code> in <code>.env</code></li> <li>Check port conflicts: <code>lsof -i :4100</code></li> <li>Check database connection (shares same DATABASE_URL)</li> </ul>"},{"location":"v2/backend/modules/media/#cors-errors-on-media-api","title":"CORS Errors on Media API","text":"<p>Problem:</p> <p>Frontend gets CORS errors when calling media API endpoints.</p> <p>Diagnosis:</p> <p>Check CORS origins:</p> <pre><code>CORS_ORIGINS=http://localhost:3000,http://localhost:3010\n</code></pre> <p>Behavior:</p> <pre><code>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</code></pre> <p>Solution:</p> <p>Add missing origins to <code>CORS_ORIGINS</code> in <code>.env</code>:</p> <pre><code>CORS_ORIGINS=http://localhost:3000,http://localhost:3010,http://localhost:3100\n</code></pre>"},{"location":"v2/backend/modules/media/#upvote-not-working","title":"Upvote Not Working","text":"<p>Problem:</p> <p>Upvote button doesn't work, returns 400 error.</p> <p>Diagnosis:</p> <p>Check request body:</p> <pre><code>curl -X POST \\\n -H \"Content-Type: application/json\" \\\n -d '{\"sessionId\":\"sess_abc123\"}' \\\n http://localhost:4100/api/media/public/456/upvote\n</code></pre> <p>Common Issues:</p> <ol> <li> <p>Missing sessionId: <pre><code>{ \"error\": \"sessionId is required\" }\n</code></pre></p> </li> <li> <p>Media not found: <pre><code>{ \"error\": \"Media not found\" }\n</code></pre></p> </li> <li> <p>Locked media: <pre><code>{ \"error\": \"Media is locked\" }\n</code></pre></p> </li> </ol> <p>Solution:</p> <ul> <li>Generate session ID in frontend: <code>crypto.randomUUID()</code> or <code>nanoid()</code></li> <li>Verify media exists in <code>public_media</code> table</li> <li>Check <code>isLocked</code> status</li> </ul>"},{"location":"v2/backend/modules/media/#reactions-not-appearing","title":"Reactions Not Appearing","text":"<p>Problem:</p> <p>Reactions submitted but not appearing in frontend.</p> <p>Diagnosis:</p> <p>Check reaction data:</p> <pre><code>SELECT * FROM video_reactions WHERE \"mediaId\" = 456 ORDER BY \"createdAt\" DESC LIMIT 10;\n</code></pre> <p>Verify:</p> <ul> <li><code>userId</code> matches authenticated user</li> <li><code>mediaId</code> matches video ID</li> <li><code>reactionType</code> is valid emoji type</li> </ul> <p>Common Issues:</p> <ol> <li>Authentication failed:</li> <li>Reaction requires auth</li> <li> <p>Check JWT token in Authorization header</p> </li> <li> <p>Invalid reaction type: <pre><code>{ \"error\": \"Invalid reaction type\" }\n</code></pre></p> </li> <li> <p>Video not found: <pre><code>{ \"error\": \"Video not found\" }\n</code></pre></p> </li> </ol> <p>Solution:</p> <ul> <li>Verify JWT token is valid and not expired</li> <li>Use valid reaction types: <code>like</code>, <code>love</code>, <code>laugh</code>, <code>wow</code>, <code>sad</code>, <code>angry</code></li> <li>Check video exists in <code>videos</code> table (not just <code>public_media</code>)</li> </ul>"},{"location":"v2/backend/modules/media/#job-queue-not-processing","title":"Job Queue Not Processing","text":"<p>Problem:</p> <p>Jobs stuck in <code>pending</code> status, never transition to <code>running</code>.</p> <p>Diagnosis:</p> <p>Check job queue:</p> <pre><code>SELECT id, type, status, \"resourceCategory\", \"queuePosition\", \"waitingReason\"\nFROM jobs\nWHERE status IN ('pending', 'queued')\nORDER BY priority DESC, \"createdAt\" ASC;\n</code></pre> <p>Common Issues:</p> <ol> <li>No worker running:</li> <li>Check if job worker process is running</li> <li> <p>Verify <code>ENABLE_MEDIA_FEATURES=true</code></p> </li> <li> <p>Resource exhaustion:</p> </li> <li>GPU jobs waiting for VRAM</li> <li> <p>Check <code>vramRequired</code> vs available VRAM</p> </li> <li> <p>Pipeline blocking:</p> </li> <li>Pipeline step depends on previous step completion</li> </ol> <p>Solution:</p> <ul> <li>Start job worker: <code>npm run worker:media</code> or check Docker Compose</li> <li>Adjust resource limits or priority</li> <li>Check pipeline configuration for blocking issues</li> </ul>"},{"location":"v2/backend/modules/media/#related-documentation","title":"Related Documentation","text":"<ul> <li>Dual API Architecture - Express + Fastify architecture</li> <li>Drizzle ORM - Drizzle query builder (media tables)</li> <li>Frontend: LibraryPage - Video library management UI</li> <li>Frontend: MediaGalleryPage - Public gallery</li> <li>Frontend: MediaViewerPage - Video player with reactions</li> <li>Features: Media Manager - Complete feature guide</li> <li>API Reference: Media - Complete endpoint reference</li> <li>User Guide: Media Admin - Managing video library</li> <li>Troubleshooting: Media API Issues - Debugging guide</li> </ul>"},{"location":"v2/backend/modules/pages/","title":"Pages Module (Landing Page Builder)","text":""},{"location":"v2/backend/modules/pages/#overview","title":"Overview","text":"<p>The Pages module provides a complete landing page builder with dual editing modes (WYSIWYG GrapesJS + direct HTML), automatic MkDocs export, and reusable block library. It enables admins to create custom landing pages visually or with code, publish them to public URLs (<code>/p/:slug</code>), and optionally export them to the MkDocs documentation site as Material theme overrides.</p> <p>Key Features:</p> <ul> <li>Dual editor modes:</li> <li>VISUAL \u2014 GrapesJS drag-and-drop WYSIWYG editor with custom blocks</li> <li>CODE \u2014 Direct HTML editing for advanced users</li> <li>Automatic slug generation from titles (collision-safe)</li> <li>MkDocs export system:</li> <li>Exports pages to <code>mkdocs/overrides/</code> directory</li> <li>Creates <code>.md</code> stub files with front matter for MkDocs Material</li> <li>Two export modes: THEMED (Jinja2 extends main.html) or STANDALONE (full HTML document)</li> <li>Configurable nav/TOC hiding via Material theme front matter</li> <li>Reusable block library (hero, text, image, CTA, features, testimonials, form)</li> <li>SEO metadata (title, description, image)</li> <li>Public rendering at <code>/p/:slug</code> route</li> <li>Sync & validation tools for managing MkDocs exports</li> <li>Path traversal protection (null bytes, <code>..</code>, encoded sequences)</li> <li>Published/draft workflow</li> </ul>"},{"location":"v2/backend/modules/pages/#file-paths","title":"File Paths","text":"File Purpose <code>api/src/modules/pages/pages-admin.routes.ts</code> Admin router with 7 endpoints (114 lines) <code>api/src/modules/pages/pages-public.routes.ts</code> Public router (1 endpoint, 21 lines) <code>api/src/modules/pages/blocks.routes.ts</code> Block library router (5 endpoints, 88 lines) <code>api/src/modules/pages/pages.service.ts</code> Landing page business logic + MkDocs export (637 lines) <code>api/src/modules/pages/blocks.service.ts</code> Block CRUD service (89 lines) <code>api/src/modules/pages/pages.schemas.ts</code> Zod validation schemas (83 lines)"},{"location":"v2/backend/modules/pages/#database-models","title":"Database Models","text":"<pre><code>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</code></pre> <p>Key Fields:</p> <ul> <li><code>blocks</code> \u2014 GrapesJS JSON state (saved on Ctrl+S in editor)</li> <li><code>htmlOutput</code> \u2014 Rendered HTML (generated by GrapesJS or manually entered in CODE mode)</li> <li><code>cssOutput</code> \u2014 Extracted CSS (from GrapesJS styles or manual entry)</li> <li><code>mkdocsPath</code> \u2014 Relative path in <code>mkdocs/overrides/</code> (e.g., <code>landing-page.html</code>)</li> <li><code>mkdocsStubPath</code> \u2014 Relative path to <code>.md</code> stub (e.g., <code>landing-page.md</code>)</li> <li><code>mkdocsExportMode</code> \u2014 THEMED (Jinja2) or STANDALONE (full HTML)</li> <li><code>mkdocsSkipExport</code> \u2014 Skip MkDocs export (for internal pages only accessible via <code>/p/:slug</code>)</li> </ul> <p>Slug Generation:</p> <pre><code>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</code></pre> <p>Example Transformations:</p> <ul> <li><code>\"Landing Page\"</code> \u2192 <code>landing-page</code></li> <li><code>\"About Us \u2014 Contact Info\"</code> \u2192 <code>about-us-contact-info</code></li> <li><code>\"Landing Page\"</code> (duplicate) \u2192 <code>landing-page-2</code></li> </ul>"},{"location":"v2/backend/modules/pages/#api-endpoints","title":"API Endpoints","text":""},{"location":"v2/backend/modules/pages/#admin-endpoints-authentication-required","title":"Admin Endpoints (Authentication Required)","text":"Method Path Auth Description GET <code>/api/pages</code> Admin roles List landing pages with pagination/filters GET <code>/api/pages/:id</code> Admin roles Get single landing page POST <code>/api/pages</code> Admin roles Create landing page PUT <code>/api/pages/:id</code> Admin roles Update landing page (triggers MkDocs export) DELETE <code>/api/pages/:id</code> Admin roles Delete landing page (removes MkDocs export) POST <code>/api/pages/sync</code> Admin roles Sync MkDocs overrides to database POST <code>/api/pages/validate</code> Admin roles Validate and repair MkDocs exports <p>Admin Roles: <code>SUPER_ADMIN</code>, <code>INFLUENCE_ADMIN</code>, <code>MAP_ADMIN</code></p>"},{"location":"v2/backend/modules/pages/#block-library-endpoints-admin-only","title":"Block Library Endpoints (Admin Only)","text":"Method Path Auth Description GET <code>/api/page-blocks</code> Admin roles List blocks with category filter GET <code>/api/page-blocks/:id</code> Admin roles Get single block POST <code>/api/page-blocks</code> Admin roles Create block PUT <code>/api/page-blocks/:id</code> Admin roles Update block DELETE <code>/api/page-blocks/:id</code> Admin roles Delete block"},{"location":"v2/backend/modules/pages/#public-endpoints-no-authentication","title":"Public Endpoints (No Authentication)","text":"Method Path Auth Description GET <code>/api/pages/:slug/view</code> 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":"<p>List landing pages with pagination, search, and filtering.</p> <p>Query Parameters:</p> 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: <code>'true'</code>, <code>'false'</code> <p>Example Request:</p> <pre><code>curl -H \"Authorization: Bearer <token>\" \\\n \"http://api.cmlite.org/api/pages?page=1&limit=10&published=true&search=about\"\n</code></pre> <p>Response (200 OK):</p> <pre><code>{\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</code></pre> <p>Search Behavior:</p> <pre><code>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</code></pre>"},{"location":"v2/backend/modules/pages/#get-apipagesid","title":"GET /api/pages/:id","text":"<p>Get single landing page with full editor state.</p> <p>Path Parameters:</p> <ul> <li><code>id</code> (string): Landing page ID</li> </ul> <p>Example Request:</p> <pre><code>curl -H \"Authorization: Bearer <token>\" \\\n \"http://api.cmlite.org/api/pages/clx1234567890\"\n</code></pre> <p>Response (200 OK):</p> <p>Returns full landing page object (same format as GET list).</p> <p>Error Responses:</p> <ul> <li><code>404 Not Found</code>: Page not found</li> </ul>"},{"location":"v2/backend/modules/pages/#post-apipages","title":"POST /api/pages","text":"<p>Create landing page with auto-generated slug.</p> <p>Request Body:</p> <pre><code>{\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</code></pre> <p>Response (201 Created):</p> <p>Returns created landing page object.</p> <p>Auto-Generated Fields:</p> <ul> <li><code>slug</code> \u2014 Generated from <code>title</code> (collision-safe)</li> <li><code>mkdocsPath</code> \u2014 Defaults to <code>${slug}.html</code> if not provided</li> </ul> <p>Validation:</p> <ul> <li><code>title</code> is required</li> <li><code>mkdocsPath</code> must end with <code>.html</code></li> <li><code>mkdocsPath</code> must not contain path traversal sequences (<code>..</code>, null bytes, encoded traversal)</li> </ul>"},{"location":"v2/backend/modules/pages/#put-apipagesid","title":"PUT /api/pages/:id","text":"<p>Update landing page. Triggers MkDocs export if published.</p> <p>Request Body (Partial):</p> <pre><code>{\n \"htmlOutput\": \"<div class=\\\"hero\\\">Updated content</div>\",\n \"cssOutput\": \".hero { background: #e74c3c; }\",\n \"published\": true\n}\n</code></pre> <p>Response (200 OK):</p> <p>Returns updated landing page object.</p> <p>Side Effects:</p> <ol> <li> <p>Slug regeneration if title changes (preserves old slug if collision): <pre><code>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</code></pre></p> </li> <li> <p>MkDocs export if <code>published === true && mkdocsSkipExport === false</code>: <pre><code>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</code></pre></p> </li> <li> <p>MkDocs cleanup if <code>published === false || mkdocsSkipExport === true</code>: <pre><code>await removeFromMkDocs(existing.mkdocsPath, existing.mkdocsStubPath);\n</code></pre></p> </li> </ol> <p>Export Workflow:</p> <pre><code>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]</code></pre>"},{"location":"v2/backend/modules/pages/#delete-apipagesid","title":"DELETE /api/pages/:id","text":"<p>Delete landing page and remove MkDocs export.</p> <p>Path Parameters:</p> <ul> <li><code>id</code> (string): Landing page ID</li> </ul> <p>Response (204 No Content):</p> <p>No response body.</p> <p>Side Effects:</p> <ul> <li>Removes MkDocs override HTML file (<code>mkdocs/overrides/{mkdocsPath}</code>)</li> <li>Removes .md stub file (<code>mkdocs/docs/{mkdocsStubPath}</code>)</li> </ul>"},{"location":"v2/backend/modules/pages/#post-apipagessync","title":"POST /api/pages/sync","text":"<p>Sync MkDocs override files to database (import untracked files, update CODE pages).</p> <p>Example Request:</p> <pre><code>curl -X POST \\\n -H \"Authorization: Bearer <token>\" \\\n \"http://api.cmlite.org/api/pages/sync\"\n</code></pre> <p>Response (200 OK):</p> <pre><code>{\n \"imported\": 2,\n \"updated\": 1,\n \"stubs\": 3\n}\n</code></pre> <p>Behavior:</p> <ol> <li> <p>Scan <code>mkdocs/overrides/</code> directory for <code>.html</code> files: <pre><code>const files = await scanOverrideFiles(MKDOCS_OVERRIDES);\n// Returns: [{ relativePath: 'foo.html', fullPath: '/full/path/foo.html' }, ...]\n</code></pre></p> </li> <li> <p>Import untracked files as CODE pages: <pre><code>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</code></pre></p> </li> <li> <p>Update CODE pages from disk (disk wins): <pre><code>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</code></pre></p> </li> <li> <p>Backfill missing .md stubs for published pages: <pre><code>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</code></pre></p> </li> </ol> <p>Use Cases:</p> <ul> <li>Manual file creation \u2014 Admin creates <code>.html</code> file directly in <code>mkdocs/overrides/</code>, then syncs to database</li> <li>Git pull \u2014 After pulling changes that add override files, sync to database</li> <li>Stub recovery \u2014 Re-create missing <code>.md</code> stub files</li> </ul>"},{"location":"v2/backend/modules/pages/#post-apipagesvalidate","title":"POST /api/pages/validate","text":"<p>Validate MkDocs exports and repair missing files.</p> <p>Example Request:</p> <pre><code>curl -X POST \\\n -H \"Authorization: Bearer <token>\" \\\n \"http://api.cmlite.org/api/pages/validate\"\n</code></pre> <p>Response (200 OK):</p> <pre><code>{\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</code></pre> <p>Behavior:</p> <ol> <li> <p>Query all published pages with <code>mkdocsSkipExport === false</code>: <pre><code>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</code></pre></p> </li> <li> <p>Check override HTML exists: <pre><code>const overridePath = path.join(MKDOCS_OVERRIDES, page.mkdocsPath);\nawait fs.access(overridePath); // Throws if missing\n</code></pre></p> </li> <li> <p>Check .md stub exists: <pre><code>const expectedStubPath = page.mkdocsStubPath || deriveStubPath(page.mkdocsPath);\nconst stubExists = await stubExistsOnDisk(expectedStubPath);\n</code></pre></p> </li> <li> <p>Repair if either missing: <pre><code>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</code></pre></p> </li> </ol> <p>Use Cases:</p> <ul> <li>Missing exports after deploy \u2014 MkDocs volume lost, re-export all pages</li> <li>Manual deletion \u2014 Admin accidentally deleted override file, repair from database</li> <li>Health check \u2014 Verify all published pages have correct exports</li> </ul>"},{"location":"v2/backend/modules/pages/#block-library-endpoint-details","title":"Block Library Endpoint Details","text":""},{"location":"v2/backend/modules/pages/#get-apipage-blocks","title":"GET /api/page-blocks","text":"<p>List blocks with optional category filter.</p> <p>Query Parameters:</p> Parameter Type Required Description category string No Filter by category <p>Example Request:</p> <pre><code>curl -H \"Authorization: Bearer <token>\" \\\n \"http://api.cmlite.org/api/page-blocks?category=hero\"\n</code></pre> <p>Response (200 OK):</p> <pre><code>[\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</code></pre> <p>Sort Order:</p> <p>Blocks are sorted by <code>sortOrder</code> ASC. Lower numbers appear first in block library panel.</p>"},{"location":"v2/backend/modules/pages/#post-apipage-blocks","title":"POST /api/page-blocks","text":"<p>Create block.</p> <p>Request Body:</p> <pre><code>{\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</code></pre> <p>Response (201 Created):</p> <p>Returns created block object.</p>"},{"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":"<p>Get published landing page by slug (no auth required).</p> <p>Path Parameters:</p> <ul> <li><code>slug</code> (string): Landing page slug</li> </ul> <p>Example Request:</p> <pre><code>curl http://api.cmlite.org/api/pages/about-us/view\n</code></pre> <p>Response (200 OK):</p> <p>Returns full landing page object (same format as admin GET).</p> <p>Filtering:</p> <ul> <li>Only returns pages with <code>published === true</code></li> <li>Throws 404 if page not found or not published</li> </ul> <p>Error Responses:</p> <ul> <li><code>404 Not Found</code>: Page not found or not published</li> </ul>"},{"location":"v2/backend/modules/pages/#service-functions","title":"Service Functions","text":""},{"location":"v2/backend/modules/pages/#pagesservicefindallfilters","title":"pagesService.findAll(filters)","text":"<p>List landing pages with pagination, search, and filtering.</p> <p>Usage:</p> <pre><code>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</code></pre>"},{"location":"v2/backend/modules/pages/#pagesservicecreatedata","title":"pagesService.create(data)","text":"<p>Create landing page with auto-generated slug and mkdocsPath.</p> <p>Usage:</p> <pre><code>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</code></pre>"},{"location":"v2/backend/modules/pages/#pagesserviceupdateid-data","title":"pagesService.update(id, data)","text":"<p>Update landing page with MkDocs export/cleanup side effects.</p> <p>Usage:</p> <pre><code>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</code></pre> <p>Export Trigger:</p> <ul> <li>Export happens if <code>published === true && mkdocsSkipExport === false && mkdocsPath && htmlOutput</code></li> <li>Cleanup happens if <code>published === false || mkdocsSkipExport === true</code></li> </ul>"},{"location":"v2/backend/modules/pages/#pagesservicesyncoverrides","title":"pagesService.syncOverrides()","text":"<p>Sync MkDocs override files to database.</p> <p>Usage:</p> <pre><code>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</code></pre> <p>Workflow:</p> <ol> <li>Scan <code>mkdocs/overrides/</code> for <code>.html</code> files</li> <li>Import untracked files as CODE pages</li> <li>Update tracked CODE pages from disk (disk wins)</li> <li>Don't overwrite VISUAL pages (managed by GrapesJS)</li> <li>Backfill missing .md stubs</li> </ol>"},{"location":"v2/backend/modules/pages/#pagesservicevalidateexports","title":"pagesService.validateExports()","text":"<p>Validate and repair MkDocs exports.</p> <p>Usage:</p> <pre><code>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</code></pre> <p>Repair Logic:</p> <pre><code>// 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</code></pre>"},{"location":"v2/backend/modules/pages/#mkdocs-export-system","title":"MkDocs Export System","text":""},{"location":"v2/backend/modules/pages/#export-modes","title":"Export Modes","text":"<p>1. THEMED (Default)</p> <p>Wraps HTML in Jinja2 template extending MkDocs Material theme:</p> <pre><code>{% extends \"main.html\" %}\n{% block content %}\n<style>\n{{ css }}\n</style>\n{{ html }}\n{% endblock %}\n</code></pre> <p>Pros:</p> <ul> <li>Inherits Material theme navigation, footer, search</li> <li>Consistent branding with main docs</li> <li>Responsive out of the box</li> </ul> <p>Cons:</p> <ul> <li>Limited control over layout</li> <li>Must work within Material theme constraints</li> </ul> <p>2. STANDALONE</p> <p>Full HTML document without Jinja2 inheritance:</p> <pre><code><!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</code></pre> <p>Pros:</p> <ul> <li>Full control over layout</li> <li>No Material theme constraints</li> <li>Custom navigation/footer</li> </ul> <p>Cons:</p> <ul> <li>No Material theme features (search, nav, etc.)</li> <li>Must implement responsive design</li> <li>Separate branding</li> </ul>"},{"location":"v2/backend/modules/pages/#md-stub-file-format","title":".md Stub File Format","text":"<p>The <code>.md</code> stub file is required for MkDocs to recognize the override template. It uses Material theme front matter to configure page appearance.</p> <p>Example:</p> <pre><code>---\ntemplate: about-us.html\nhide:\n - navigation\n - toc\ntitle: \"About Us \u2014 Changemaker Lite\"\ndescription: \"Learn about our mission and values\"\n---\n</code></pre> <p>Front Matter Fields:</p> <ul> <li><code>template</code> \u2014 Override filename (relative to <code>custom_dir</code>/overrides)</li> <li><code>hide</code> \u2014 Hide Material theme elements (<code>navigation</code>, <code>toc</code>)</li> <li><code>title</code> \u2014 Page title (SEO)</li> <li><code>description</code> \u2014 Page description (SEO)</li> </ul> <p>Generation:</p> <pre><code>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</code></pre>"},{"location":"v2/backend/modules/pages/#path-validation","title":"Path Validation","text":"<p>All <code>mkdocsPath</code> values are validated to prevent path traversal attacks:</p> <pre><code>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</code></pre> <p>Blocked Patterns:</p> <ul> <li>Null bytes (<code>\\0</code>)</li> <li>Path traversal (<code>..</code>)</li> <li>Absolute paths (<code>/etc/passwd</code>)</li> <li>Encoded traversal (<code>%2e%2e/</code>, <code>%2E%2E/</code>)</li> <li>Non-HTML files (must end with <code>.html</code>)</li> </ul>"},{"location":"v2/backend/modules/pages/#validation-schemas","title":"Validation Schemas","text":""},{"location":"v2/backend/modules/pages/#create-landing-page-schema","title":"Create Landing Page Schema","text":"<pre><code>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</code></pre> <p>Defaults:</p> <ul> <li><code>editorMode</code>: <code>VISUAL</code></li> <li><code>blocks</code>: <code>{}</code></li> <li><code>mkdocsExportMode</code>: <code>THEMED</code></li> <li><code>mkdocsHideNav</code>: <code>true</code></li> <li><code>mkdocsHideToc</code>: <code>true</code></li> <li><code>mkdocsSkipExport</code>: <code>false</code></li> <li><code>published</code>: <code>false</code></li> </ul>"},{"location":"v2/backend/modules/pages/#create-page-block-schema","title":"Create Page Block Schema","text":"<pre><code>export const createPageBlockSchema = z.object({\n type: z.string().min(1, 'Type is required'),\n label: z.string().min(1, 'Label is required'),\n schema: z.any().optional().default({}),\n defaults: z.any().optional().default({}),\n thumbnail: z.string().optional(),\n category: z.string().optional(),\n sortOrder: z.number().int().optional().default(0),\n});\n</code></pre> <p>Example Valid Input:</p> <pre><code>{\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</code></pre>"},{"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":"<pre><code>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</code></pre>"},{"location":"v2/backend/modules/pages/#admin-publish-page-triggers-mkdocs-export","title":"Admin: Publish Page (Triggers MkDocs Export)","text":"<pre><code>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</code></pre>"},{"location":"v2/backend/modules/pages/#admin-sync-mkdocs-overrides","title":"Admin: Sync MkDocs Overrides","text":"<pre><code>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</code></pre>"},{"location":"v2/backend/modules/pages/#admin-validate-and-repair-exports","title":"Admin: Validate and Repair Exports","text":"<pre><code>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</code></pre>"},{"location":"v2/backend/modules/pages/#public-render-landing-page","title":"Public: Render Landing Page","text":"<pre><code>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</code></pre>"},{"location":"v2/backend/modules/pages/#frontend-integration","title":"Frontend Integration","text":"<p>The LandingPagesPage component (<code>admin/src/pages/LandingPagesPage.tsx</code>) provides:</p> <ul> <li>Paginated pages table with search and published filter</li> <li>Create page button (opens modal with title input)</li> <li>Edit button (navigates to full-screen GrapesJS editor)</li> <li>Publish/unpublish toggle (triggers MkDocs export)</li> <li>Delete confirmation modal</li> <li>Sync button (syncs MkDocs overrides to database)</li> <li>Validate button (repairs missing exports)</li> <li>Settings modal (configure MkDocs export options)</li> </ul> <p>State Management:</p> <pre><code>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</code></pre> <p>Page Editor:</p> <p>The PageEditorPage component (<code>admin/src/pages/PageEditorPage.tsx</code>) provides:</p> <ul> <li>Full-screen GrapesJS editor (no AppLayout)</li> <li>Custom block library (hero, text, image, CTA, features, testimonials, form)</li> <li>Ctrl+S save (forwardRef to GrapesJS instance)</li> <li>Mobile warning (GrapesJS is desktop-only)</li> <li>Visual/Code mode toggle</li> <li>Auto-save on blur (optional)</li> </ul> <p>Public Renderer:</p> <p>The LandingPage component (<code>admin/src/pages/public/LandingPage.tsx</code>) provides:</p> <ul> <li>Public route at <code>/p/:slug</code></li> <li>Renders <code>htmlOutput</code> with <code>cssOutput</code></li> <li>SEO metadata from <code>seoTitle</code>, <code>seoDescription</code>, <code>seoImage</code></li> <li>404 handling for unpublished or missing pages</li> </ul>"},{"location":"v2/backend/modules/pages/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/backend/modules/pages/#mkdocs-export-caching","title":"MkDocs Export Caching","text":"<p>MkDocs exports are triggered on update, not on every GET request. This avoids I/O overhead.</p> <p>Export Trigger:</p> <pre><code>if (page.published && !page.mkdocsSkipExport && page.mkdocsPath && page.htmlOutput) {\n await exportToMkDocs({/* ... */});\n}\n</code></pre> <p>No Export:</p> <ul> <li>Draft pages (<code>published === false</code>)</li> <li>Skipped pages (<code>mkdocsSkipExport === true</code>)</li> <li>Pages without HTML output</li> </ul>"},{"location":"v2/backend/modules/pages/#slug-collision-handling","title":"Slug Collision Handling","text":"<p>The slug collision resolver loops until unique slug found. To avoid infinite loops, it uses suffix counter:</p> <pre><code>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</code></pre> <p>Worst-case:</p> <ul> <li>O(n) queries where n = number of pages with same base slug</li> <li>In practice, n is very small (< 10)</li> </ul>"},{"location":"v2/backend/modules/pages/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/backend/modules/pages/#mkdocs-override-not-appearing","title":"MkDocs Override Not Appearing","text":"<p>Problem:</p> <p>Page is published but doesn't appear on MkDocs site.</p> <p>Diagnosis:</p> <ol> <li> <p>Check override file exists: <pre><code>ls mkdocs/overrides/about-us.html\n</code></pre></p> </li> <li> <p>Check stub file exists: <pre><code>ls mkdocs/docs/about-us.md\n</code></pre></p> </li> <li> <p>Check stub front matter: <pre><code>cat mkdocs/docs/about-us.md\n</code></pre></p> </li> </ol> <p>Verify <code>template:</code> points to override filename (not path): <pre><code>template: about-us.html # Correct\ntemplate: overrides/about-us.html # WRONG \u2014 causes TemplateNotFound\n</code></pre></p> <ol> <li>Check MkDocs logs: <pre><code>docker compose logs -f mkdocs\n</code></pre></li> </ol> <p>Solutions:</p> <ul> <li> <p>Missing files: Run validate endpoint to repair: <pre><code>curl -X POST -H \"Authorization: Bearer <token>\" \\\n http://api.cmlite.org/api/pages/validate\n</code></pre></p> </li> <li> <p>Wrong template path: Front matter <code>template:</code> value is relative to template search paths. Use filename only.</p> </li> <li> <p>MkDocs rebuild: Restart MkDocs container: <pre><code>docker compose restart mkdocs\n</code></pre></p> </li> </ul>"},{"location":"v2/backend/modules/pages/#path-traversal-validation-error","title":"Path Traversal Validation Error","text":"<p>Problem:</p> <p>Creating page fails with \"Path traversal not allowed\" error.</p> <p>Diagnosis:</p> <p>Check <code>mkdocsPath</code> value for blocked patterns:</p> <pre><code>// 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</code></pre> <p>Solution:</p> <p>Use safe filenames without path traversal sequences. Subfolders are allowed but must not contain <code>..</code>.</p>"},{"location":"v2/backend/modules/pages/#code-page-overwritten-by-disk","title":"CODE Page Overwritten by Disk","text":"<p>Problem:</p> <p>Manual edits to CODE page in database are lost after sync.</p> <p>Diagnosis:</p> <p>Check <code>editorMode</code>:</p> <pre><code>SELECT id, slug, \"editorMode\" FROM landing_pages WHERE slug = 'my-page';\n</code></pre> <p>Behavior:</p> <ul> <li>CODE pages: Disk wins. Sync overwrites database <code>htmlOutput</code> from disk.</li> <li>VISUAL pages: Database wins. Sync does not overwrite GrapesJS-managed pages.</li> </ul> <p>Solution:</p> <ul> <li> <p>Option 1: Edit file on disk directly: <pre><code>vim mkdocs/overrides/my-page.html\n# Then sync\ncurl -X POST -H \"Authorization: Bearer <token>\" http://api.cmlite.org/api/pages/sync\n</code></pre></p> </li> <li> <p>Option 2: Change <code>editorMode</code> to <code>VISUAL</code> if you want database to be source of truth: <pre><code>UPDATE landing_pages SET \"editorMode\" = 'VISUAL' WHERE slug = 'my-page';\n</code></pre></p> </li> </ul>"},{"location":"v2/backend/modules/pages/#stub-template-not-found","title":"Stub Template Not Found","text":"<p>Problem:</p> <p>MkDocs build fails with <code>TemplateNotFound</code> error.</p> <p>Diagnosis:</p> <p>Check stub front matter:</p> <pre><code>cat mkdocs/docs/about-us.md\n</code></pre> <p>Common Mistakes:</p> <pre><code># WRONG \u2014 includes directory path\ntemplate: overrides/about-us.html\n\n# CORRECT \u2014 filename only\ntemplate: about-us.html\n</code></pre> <p>Why:</p> <p>MkDocs Material <code>template:</code> searches in <code>custom_dir</code> (which includes <code>/overrides</code>). Using <code>overrides/</code> in the template value causes it to look for <code>overrides/overrides/about-us.html</code>.</p> <p>Solution:</p> <p>Re-export page to fix stub:</p> <pre><code>curl -X POST -H \"Authorization: Bearer <token>\" \\\n http://api.cmlite.org/api/pages/validate\n</code></pre>"},{"location":"v2/backend/modules/pages/#related-documentation","title":"Related Documentation","text":"<ul> <li>Frontend: LandingPagesPage - Landing page manager UI</li> <li>Frontend: PageEditorPage - GrapesJS editor wrapper</li> <li>Frontend: Public Landing Page - Public renderer</li> <li>Features: Landing Page Builder - Complete feature guide</li> <li>MkDocs Integration - MkDocs export system</li> <li>API Reference: Pages - Complete endpoint reference</li> <li>User Guide: Content Editor - Creating landing pages</li> <li>Troubleshooting: MkDocs Issues - MkDocs debugging guide</li> </ul>"},{"location":"v2/backend/modules/representatives/","title":"Representatives Module","text":""},{"location":"v2/backend/modules/representatives/#overview","title":"Overview","text":"<p>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.</p> <p>Key Features:</p> <ul> <li>Canadian representative lookup via Represent API (MPs, MPPs, councillors)</li> <li>Intelligent cache-first strategy with fire-and-forget cache writes</li> <li>Rate limiting (55 requests/minute, under Represent API's 60/min limit)</li> <li>Representative deduplication (centroid + concordance results)</li> <li>Public postal code lookup (no auth required)</li> <li>Admin cache management (view, clear, stats)</li> <li>Integration with postal codes module for location metadata</li> <li>Health check endpoint for API connectivity testing</li> </ul>"},{"location":"v2/backend/modules/representatives/#file-paths","title":"File Paths","text":"File Purpose <code>api/src/modules/influence/representatives/representatives.routes.ts</code> Router with 8 endpoints (2 public, 6 admin) <code>api/src/modules/influence/representatives/representatives.service.ts</code> Representative business logic + Represent API integration <code>api/src/modules/influence/representatives/representatives.schemas.ts</code> Zod validation schemas <code>api/src/modules/influence/representatives/represent-api.client.ts</code> Represent API HTTP client with rate limiting"},{"location":"v2/backend/modules/representatives/#database-model","title":"Database Model","text":"<pre><code>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</code></pre> <p>Field Descriptions:</p> <ul> <li><code>postalCode</code> \u2014 Canadian postal code (e.g., \"M5H 2N2\")</li> <li><code>name</code> \u2014 Representative's full name</li> <li><code>email</code> \u2014 Contact email address</li> <li><code>districtName</code> \u2014 Electoral district name (e.g., \"Toronto Centre\")</li> <li><code>electedOffice</code> \u2014 Position (e.g., \"MP\", \"MPP\", \"Councillor\")</li> <li><code>partyName</code> \u2014 Political party affiliation</li> <li><code>representativeSetName</code> \u2014 Data source identifier (e.g., \"House of Commons\")</li> <li><code>url</code> \u2014 Representative's official website</li> <li><code>photoUrl</code> \u2014 Profile photo URL</li> <li><code>offices</code> \u2014 JSON array of office locations with contact info</li> <li><code>cachedAt</code> \u2014 Timestamp when cached from Represent API</li> </ul>"},{"location":"v2/backend/modules/representatives/#api-endpoints","title":"API Endpoints","text":""},{"location":"v2/backend/modules/representatives/#public-endpoints-no-authentication","title":"Public Endpoints (No Authentication)","text":"Method Path Description GET <code>/api/representatives/by-postal/:postalCode</code> Lookup representatives by postal code (cache-first) GET <code>/api/representatives/test-connection</code> Test Represent API connectivity"},{"location":"v2/backend/modules/representatives/#admin-endpoints-authentication-required","title":"Admin Endpoints (Authentication Required)","text":"Method Path Auth Description GET <code>/api/representatives/cache-stats</code> Admin roles Get cache statistics GET <code>/api/representatives</code> Admin roles List all cached representatives (paginated) GET <code>/api/representatives/:id</code> Admin roles Get single cached representative DELETE <code>/api/representatives/by-postal/:postalCode</code> Admin roles Clear cache for postal code DELETE <code>/api/representatives/:id</code> Admin roles Delete single cached representative <p>Admin Roles: <code>SUPER_ADMIN</code>, <code>INFLUENCE_ADMIN</code>, <code>MAP_ADMIN</code></p>"},{"location":"v2/backend/modules/representatives/#public-endpoint-details","title":"Public Endpoint Details","text":""},{"location":"v2/backend/modules/representatives/#get-apirepresentativesby-postalpostalcode","title":"GET /api/representatives/by-postal/:postalCode","text":"<p>Lookup representatives by Canadian postal code. Uses cache-first strategy: returns cached results if available, otherwise calls Represent API and caches results asynchronously.</p> <p>Path Parameters:</p> <ul> <li><code>postalCode</code> (string): Canadian postal code (e.g., \"M5H2N2\" or \"M5H 2N2\")</li> </ul> <p>Query Parameters:</p> Parameter Type Required Default Description refresh boolean No false Force API call even if cached data exists <p>Example Request:</p> <pre><code># 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</code></pre> <p>Response (200 OK):</p> <pre><code>{\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</code></pre> <p>Response Fields:</p> <ul> <li><code>source</code> \u2014 Data source: <code>\"cache\"</code> (from database) or <code>\"api\"</code> (fresh from Represent API)</li> <li><code>postalCode</code> \u2014 Normalized postal code</li> <li><code>location</code> \u2014 City and province from PostalCodeCache table</li> <li><code>representatives</code> \u2014 Array of representative objects</li> </ul> <p>Error Responses:</p> <ul> <li><code>400 Bad Request</code>: Invalid postal code format</li> <li><code>404 Not Found</code>: Postal code not found in Represent API</li> <li><code>429 Too Many Requests</code>: Rate limit exceeded (55/min)</li> <li><code>500 Internal Server Error</code>: Represent API unreachable or other error</li> </ul> <p>Caching Strategy:</p> <pre><code>// 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</code></pre> <p>Deduplication:</p> <p>Representatives from both <code>representatives_centroid</code> and <code>representatives_concordance</code> are merged and deduplicated by <code>name|elected_office</code> key to avoid duplicate entries.</p> <pre><code>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</code></pre>"},{"location":"v2/backend/modules/representatives/#get-apirepresentativestest-connection","title":"GET /api/representatives/test-connection","text":"<p>Test connectivity to the Represent API.</p> <p>Example Request:</p> <pre><code>curl \"http://api.cmlite.org/api/representatives/test-connection\"\n</code></pre> <p>Response (200 OK):</p> <pre><code>{\n \"ok\": true,\n \"message\": \"Represent API is reachable\"\n}\n</code></pre> <p>Response (200 OK, API Down):</p> <pre><code>{\n \"ok\": false,\n \"message\": \"HTTP 503\"\n}\n</code></pre> <p>Use Cases:</p> <ul> <li>Health checks for monitoring dashboards</li> <li>Troubleshooting representative lookup issues</li> <li>Verifying API configuration in admin settings</li> </ul>"},{"location":"v2/backend/modules/representatives/#admin-endpoint-details","title":"Admin Endpoint Details","text":""},{"location":"v2/backend/modules/representatives/#get-apirepresentativescache-stats","title":"GET /api/representatives/cache-stats","text":"<p>Get cache statistics for the representatives cache.</p> <p>Authentication: Required (Admin roles)</p> <p>Example Request:</p> <pre><code>curl -H \"Authorization: Bearer <token>\" \\\n \"http://api.cmlite.org/api/representatives/cache-stats\"\n</code></pre> <p>Response (200 OK):</p> <pre><code>{\n \"totalRepresentatives\": 1247,\n \"postalCodesWithRepresentatives\": 412,\n \"totalPostalCodes\": 450\n}\n</code></pre> <p>Field Descriptions:</p> <ul> <li><code>totalRepresentatives</code> \u2014 Total cached representative records</li> <li><code>postalCodesWithRepresentatives</code> \u2014 Unique postal codes with cached representatives</li> <li><code>totalPostalCodes</code> \u2014 Total postal codes in PostalCodeCache table (includes codes without representatives)</li> </ul>"},{"location":"v2/backend/modules/representatives/#get-apirepresentatives","title":"GET /api/representatives","text":"<p>List all cached representatives with pagination and search.</p> <p>Authentication: Required (Admin roles)</p> <p>Query Parameters:</p> Parameter Type Required Default Description page number No 1 Page number limit number No 20 Results per page (max 100) search string No - Search name, email, district, or office postalCode string No - Filter by postal code <p>Example Request:</p> <pre><code>curl -H \"Authorization: Bearer <token>\" \\\n \"http://api.cmlite.org/api/representatives?page=1&limit=10&search=Toronto&postalCode=M5H2N2\"\n</code></pre> <p>Response (200 OK):</p> <pre><code>{\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</code></pre> <p>Search Logic:</p> <p>Search term is matched against name, email, district name, or elected office (case-insensitive):</p> <pre><code>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</code></pre>"},{"location":"v2/backend/modules/representatives/#get-apirepresentativesid","title":"GET /api/representatives/:id","text":"<p>Get single cached representative by ID.</p> <p>Authentication: Required (Admin roles)</p> <p>Path Parameters:</p> <ul> <li><code>id</code> (string): Representative ID (cuid)</li> </ul> <p>Example Request:</p> <pre><code>curl -H \"Authorization: Bearer <token>\" \\\n \"http://api.cmlite.org/api/representatives/clx1234567890\"\n</code></pre> <p>Response (200 OK):</p> <p>Returns single representative object (same format as list).</p> <p>Error Responses:</p> <ul> <li><code>404 Not Found</code>: Representative not found</li> </ul>"},{"location":"v2/backend/modules/representatives/#delete-apirepresentativesby-postalpostalcode","title":"DELETE /api/representatives/by-postal/:postalCode","text":"<p>Clear all cached representatives for a specific postal code.</p> <p>Authentication: Required (Admin roles)</p> <p>Path Parameters:</p> <ul> <li><code>postalCode</code> (string): Canadian postal code</li> </ul> <p>Example Request:</p> <pre><code>curl -X DELETE -H \"Authorization: Bearer <token>\" \\\n \"http://api.cmlite.org/api/representatives/by-postal/M5H2N2\"\n</code></pre> <p>Response (200 OK):</p> <pre><code>{\n \"deleted\": 3,\n \"postalCode\": \"M5H2N2\"\n}\n</code></pre> <p>Use Cases:</p> <ul> <li>Force cache refresh for specific postal code</li> <li>Remove stale data after election</li> <li>Troubleshoot incorrect representative data</li> </ul>"},{"location":"v2/backend/modules/representatives/#delete-apirepresentativesid","title":"DELETE /api/representatives/:id","text":"<p>Delete single cached representative by ID.</p> <p>Authentication: Required (Admin roles)</p> <p>Path Parameters:</p> <ul> <li><code>id</code> (string): Representative ID (cuid)</li> </ul> <p>Example Request:</p> <pre><code>curl -X DELETE -H \"Authorization: Bearer <token>\" \\\n \"http://api.cmlite.org/api/representatives/clx1234567890\"\n</code></pre> <p>Response (204 No Content):</p> <p>No response body.</p> <p>Error Responses:</p> <ul> <li><code>404 Not Found</code>: Representative not found</li> </ul>"},{"location":"v2/backend/modules/representatives/#represent-api-integration","title":"Represent API Integration","text":""},{"location":"v2/backend/modules/representatives/#api-client","title":"API Client","text":"<p>The <code>represent-api.client.ts</code> file provides a typed HTTP client for the Represent API.</p> <p>Base URL:</p> <pre><code>const REPRESENT_API_URL = 'https://represent.opennorth.ca';\n</code></pre> <p>Configuration:</p> <p>Set <code>REPRESENT_API_URL</code> in <code>.env</code> to override (default: <code>https://represent.opennorth.ca</code>).</p> <p>Methods:</p> <pre><code>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</code></pre>"},{"location":"v2/backend/modules/representatives/#rate-limiting","title":"Rate Limiting","text":"<p>Limits:</p> <ul> <li>Represent API: 60 requests/minute</li> <li>Changemaker Lite: 55 requests/minute (safety margin)</li> </ul> <p>Implementation:</p> <p>In-memory sliding window rate limiter:</p> <pre><code>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</code></pre> <p>Behavior:</p> <ul> <li>If rate limit exceeded: throws <code>Error('Represent API rate limit reached. Please try again in a minute.')</code></li> <li>Returns 429 status to client</li> <li>Resets after 1 minute</li> </ul>"},{"location":"v2/backend/modules/representatives/#response-schema","title":"Response Schema","text":"<pre><code>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</code></pre> <p>Centroid vs. Concordance:</p> <ul> <li><code>representatives_centroid</code> \u2014 Representatives found using the postal code's geographic centroid</li> <li><code>representatives_concordance</code> \u2014 Representatives found using postal code concordance tables (may be more accurate for boundary-edge postal codes)</li> <li>Both arrays are merged and deduplicated by Changemaker Lite</li> </ul>"},{"location":"v2/backend/modules/representatives/#service-functions","title":"Service Functions","text":""},{"location":"v2/backend/modules/representatives/#representativesservicelookupbypostalcodecode-forcerefresh","title":"representativesService.lookupByPostalCode(code, forceRefresh)","text":"<p>Cache-first representative lookup.</p> <p>Parameters:</p> <ul> <li><code>code</code> (string): Canadian postal code</li> <li><code>forceRefresh</code> (boolean, default: false): Skip cache and force API call</li> </ul> <p>Returns:</p> <pre><code>{\n source: 'cache' | 'api';\n postalCode: string;\n location: { city: string | null; province: string | null };\n representatives: Representative[];\n}\n</code></pre> <p>Logic Flow:</p> <ol> <li>Check cache unless <code>forceRefresh=true</code></li> <li>If cached data found, return immediately with <code>source: 'cache'</code></li> <li>If no cache or <code>forceRefresh</code>, call Represent API</li> <li>Merge centroid + concordance representatives and deduplicate</li> <li>Fire-and-forget cache write (delete old, insert new, upsert postal code)</li> <li>Return API results with <code>source: 'api'</code> (don't wait for cache)</li> </ol> <p>Fire-and-Forget Caching:</p> <pre><code>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</code></pre> <p>Why Fire-and-Forget?</p> <ul> <li>Returns API results to user immediately (faster response)</li> <li>Cache failures don't block user requests</li> <li>Next lookup will use cached data if write succeeds</li> <li>Errors logged for monitoring but don't propagate to user</li> </ul>"},{"location":"v2/backend/modules/representatives/#representativesservicefindallfilters","title":"representativesService.findAll(filters)","text":"<p>List cached representatives with pagination and search.</p> <p>Parameters:</p> <pre><code>{\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</code></pre> <p>Returns:</p> <pre><code>{\n representatives: Representative[];\n pagination: {\n page: number;\n limit: number;\n total: number;\n totalPages: number;\n };\n}\n</code></pre>"},{"location":"v2/backend/modules/representatives/#representativesservicefindbyidid","title":"representativesService.findById(id)","text":"<p>Get single cached representative by ID.</p> <p>Throws: <code>AppError(404)</code> if not found</p>"},{"location":"v2/backend/modules/representatives/#representativesserviceclearbypostalcodecode","title":"representativesService.clearByPostalCode(code)","text":"<p>Delete all cached representatives for a postal code.</p> <p>Returns:</p> <pre><code>{\n deleted: number; // Count of deleted records\n postalCode: string;\n}\n</code></pre>"},{"location":"v2/backend/modules/representatives/#representativesservicedeletebyidid","title":"representativesService.deleteById(id)","text":"<p>Delete single cached representative by ID.</p> <p>Throws: <code>AppError(404)</code> if not found</p>"},{"location":"v2/backend/modules/representatives/#representativesservicetestapiconnection","title":"representativesService.testApiConnection()","text":"<p>Test connectivity to Represent API.</p> <p>Returns:</p> <pre><code>{\n ok: boolean;\n message: string;\n}\n</code></pre> <p>Implementation:</p> <p>Calls Represent API's <code>/boundary-sets/?limit=1</code> endpoint (lightweight health check).</p>"},{"location":"v2/backend/modules/representatives/#representativesservicegetcachestats","title":"representativesService.getCacheStats()","text":"<p>Get cache statistics.</p> <p>Returns:</p> <pre><code>{\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</code></pre> <p>Implementation:</p> <pre><code>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</code></pre>"},{"location":"v2/backend/modules/representatives/#validation-schemas","title":"Validation Schemas","text":""},{"location":"v2/backend/modules/representatives/#list-representatives-schema","title":"List Representatives Schema","text":"<pre><code>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</code></pre> <p>Coercion:</p> <ul> <li><code>page</code> and <code>limit</code> coerced from query string to number</li> <li>Invalid values fallback to defaults</li> </ul>"},{"location":"v2/backend/modules/representatives/#integration-with-postal-codes-module","title":"Integration with Postal Codes Module","text":"<p>The representatives module integrates with the postal codes module (<code>api/src/modules/influence/postal-codes/</code>) for location metadata.</p> <p>PostalCodeCache Model:</p> <pre><code>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</code></pre> <p>Integration Points:</p> <ol> <li>Lookup: When returning cached representatives, fetch city/province from <code>PostalCodeCache</code>:</li> </ol> <pre><code>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</code></pre> <ol> <li>Cache Write: After calling Represent API, upsert postal code with location data:</li> </ol> <pre><code>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</code></pre>"},{"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":"<pre><code>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</code></pre>"},{"location":"v2/backend/modules/representatives/#admin-get-cache-statistics","title":"Admin: Get Cache Statistics","text":"<pre><code>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</code></pre>"},{"location":"v2/backend/modules/representatives/#admin-clear-cache-for-postal-code","title":"Admin: Clear Cache for Postal Code","text":"<pre><code>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</code></pre>"},{"location":"v2/backend/modules/representatives/#admin-search-cached-representatives","title":"Admin: Search Cached Representatives","text":"<pre><code>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</code></pre>"},{"location":"v2/backend/modules/representatives/#frontend-integration","title":"Frontend Integration","text":"<p>The RepresentativesPage component (<code>admin/src/pages/RepresentativesPage.tsx</code>) provides:</p> <ul> <li>Cache statistics dashboard (total reps, postal codes, coverage)</li> <li>Representative cache table with pagination</li> <li>Search by name, email, district, or office</li> <li>Filter by postal code</li> <li>Clear cache by postal code (bulk action)</li> <li>Delete individual cached representatives</li> <li>Postal code lookup tool (test Represent API)</li> <li>Connection test (verify API reachability)</li> <li>Refresh button (force API call for postal code)</li> </ul> <p>State Management:</p> <pre><code>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</code></pre>"},{"location":"v2/backend/modules/representatives/#performance-considerations","title":"Performance Considerations","text":"<p>Cache-First Strategy:</p> <ul> <li>Cached lookups: <10ms (database query)</li> <li>API lookups: 200-500ms (external API call)</li> <li>Fire-and-forget writes don't block user response</li> </ul> <p>Rate Limiting:</p> <ul> <li>55 requests/minute limit prevents Represent API 429 errors</li> <li>In-memory sliding window (no Redis overhead)</li> <li>Returns 429 status to client when limit exceeded</li> </ul> <p>Database Indexing:</p> <ul> <li><code>@@index([postalCode])</code> \u2014 Fast lookup by postal code</li> <li>Ordered by <code>cachedAt DESC</code> \u2014 Recent lookups first</li> </ul> <p>Deduplication:</p> <ul> <li>Prevents duplicate representatives from centroid + concordance results</li> <li>Reduces database storage and frontend rendering load</li> </ul>"},{"location":"v2/backend/modules/representatives/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/backend/modules/representatives/#issue-represent-api-rate-limit-reached","title":"Issue: \"Represent API rate limit reached\"","text":"<p>Cause: More than 55 requests in 60-second window</p> <p>Solution:</p> <ul> <li>Wait 1 minute and retry</li> <li>Use cached data (don't force refresh)</li> <li>Batch postal code lookups instead of sequential</li> </ul>"},{"location":"v2/backend/modules/representatives/#issue-cached-data-is-stale","title":"Issue: Cached data is stale","text":"<p>Cause: Representative changed after election</p> <p>Solution:</p> <ul> <li>Force refresh: <code>GET /api/representatives/by-postal/:postalCode?refresh=true</code></li> <li>Admin clear cache: <code>DELETE /api/representatives/by-postal/:postalCode</code></li> <li>Cache will be refreshed on next lookup</li> </ul>"},{"location":"v2/backend/modules/representatives/#issue-postal-code-returns-no-representatives","title":"Issue: Postal code returns no representatives","text":"<p>Cause: Invalid postal code or Represent API doesn't have data</p> <p>Solution:</p> <ul> <li>Verify postal code format (e.g., \"M5H2N2\" or \"M5H 2N2\")</li> <li>Check Represent API directly: https://represent.opennorth.ca/postcodes/M5H2N2/</li> <li>Ensure postal code is Canadian (Represent API is Canada-only)</li> </ul>"},{"location":"v2/backend/modules/representatives/#issue-duplicate-representatives-in-cache","title":"Issue: Duplicate representatives in cache","text":"<p>Cause: Deduplication bug or manual database insertion</p> <p>Solution:</p> <ul> <li>Clear cache: <code>DELETE /api/representatives/by-postal/:postalCode</code></li> <li>Next lookup will re-deduplicate from API</li> </ul>"},{"location":"v2/backend/modules/representatives/#related-documentation","title":"Related Documentation","text":"<ul> <li>Postal Codes Module - Postal code cache integration</li> <li>Campaigns Module - Campaign email sending to representatives</li> <li>Frontend: RepresentativesPage - Cache management UI</li> <li>Frontend: Public Campaign Page - Public representative lookup</li> <li>API Reference: Representatives - Complete endpoint reference</li> <li>Feature: Influence System - Representative lookup feature guide</li> <li>Represent API Documentation - Official Represent API docs</li> </ul>"},{"location":"v2/backend/modules/responses/","title":"Responses Module","text":""},{"location":"v2/backend/modules/responses/#overview","title":"Overview","text":"<p>The Responses module manages the public response wall for advocacy campaigns, allowing users to share representative responses (emails, letters, phone calls, etc.) with email verification, upvoting, and admin moderation. It features a dual verification system (verify or report links), IP-based and user-based upvoting, and comprehensive moderation tools.</p> <p>Key Features:</p> <ul> <li>Public response submission with representative verification emails</li> <li>Email verification flow (30-day expiry, verify or report links)</li> <li>Upvoting system (IP-based for anonymous, user-based for logged-in users)</li> <li>Admin moderation (PENDING \u2192 APPROVED/REJECTED workflow)</li> <li>Response statistics (total, verified, upvotes, level breakdown)</li> <li>Public response listing with sorting (recent, upvotes, verified)</li> <li>Rate limiting (prevents spam submissions)</li> <li>Anonymous submissions (submitter name/email hidden)</li> <li>Response types (email, letter, phone call, meeting, social media, other)</li> <li>HTML result pages for email verification links</li> </ul>"},{"location":"v2/backend/modules/responses/#file-paths","title":"File Paths","text":"File Purpose <code>api/src/modules/influence/responses/responses.routes.ts</code> 3 routers (campaign public, responses public, admin) with 12 endpoints <code>api/src/modules/influence/responses/responses.service.ts</code> Response business logic + email verification <code>api/src/modules/influence/responses/responses.schemas.ts</code> Zod validation schemas"},{"location":"v2/backend/modules/responses/#database-models","title":"Database Models","text":""},{"location":"v2/backend/modules/responses/#representativeresponse","title":"RepresentativeResponse","text":"<pre><code>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</code></pre>"},{"location":"v2/backend/modules/responses/#responseupvote","title":"ResponseUpvote","text":"<pre><code>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</code></pre> <p>Upvoting Logic:</p> <ul> <li>Logged-in users: tracked by <code>userId</code> (allows upvoting from multiple devices)</li> <li>Anonymous users: tracked by <code>upvotedIp</code> (prevents duplicate upvotes from same IP)</li> <li>Unique constraints ensure users can't upvote same response multiple times</li> </ul>"},{"location":"v2/backend/modules/responses/#api-endpoints","title":"API Endpoints","text":""},{"location":"v2/backend/modules/responses/#campaign-scoped-public-endpoints-no-authentication","title":"Campaign-Scoped Public Endpoints (No Authentication)","text":"Method Path Description GET <code>/api/campaigns/:slug/responses</code> List approved responses for campaign GET <code>/api/campaigns/:slug/response-stats</code> Get response statistics for campaign POST <code>/api/campaigns/:slug/responses</code> 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 <code>/api/responses/:id/upvote</code> Upvote a response DELETE <code>/api/responses/:id/upvote</code> Remove upvote from response GET <code>/api/responses/:id/verify/:token</code> Verify response (returns HTML page) GET <code>/api/responses/:id/report/:token</code> 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 <code>/api/responses</code> Admin roles List all responses (paginated, filtered) PATCH <code>/api/responses/:id/status</code> Admin roles Update response status POST <code>/api/responses/:id/resend-verification</code> Admin roles Resend verification email DELETE <code>/api/responses/:id</code> Admin roles Delete response <p>Admin Roles: <code>SUPER_ADMIN</code>, <code>INFLUENCE_ADMIN</code>, <code>MAP_ADMIN</code></p>"},{"location":"v2/backend/modules/responses/#public-endpoint-details","title":"Public Endpoint Details","text":""},{"location":"v2/backend/modules/responses/#post-apicampaignsslugresponses","title":"POST /api/campaigns/:slug/responses","text":"<p>Submit a new representative response to a campaign.</p> <p>Rate Limiting: 10 requests per minute per IP</p> <p>Path Parameters:</p> <ul> <li><code>slug</code> (string): Campaign slug</li> </ul> <p>Request Body:</p> <pre><code>{\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</code></pre> <p>Field Descriptions:</p> <ul> <li><code>representativeName</code> (required): Representative's full name</li> <li><code>representativeLevel</code> (required): Government level (<code>FEDERAL</code>, <code>PROVINCIAL</code>, <code>MUNICIPAL</code>, <code>SCHOOL_BOARD</code>)</li> <li><code>responseType</code> (required): Response type (<code>EMAIL</code>, <code>LETTER</code>, <code>PHONE_CALL</code>, <code>MEETING</code>, <code>SOCIAL_MEDIA</code>, <code>OTHER</code>)</li> <li><code>responseText</code> (required): Full text of representative's response</li> <li><code>representativeTitle</code> (optional): Representative's title/position</li> <li><code>representativeEmail</code> (optional): Representative's email (required if <code>sendVerification=true</code>)</li> <li><code>userComment</code> (optional): Submitter's comment about the response</li> <li><code>submittedByName</code> (optional): Submitter's name (not shown if <code>isAnonymous=true</code>)</li> <li><code>submittedByEmail</code> (optional): Submitter's email (not shown publicly)</li> <li><code>isAnonymous</code> (optional, default: false): Hide submitter name on public wall</li> <li><code>sendVerification</code> (optional, default: false): Send verification email to representative</li> </ul> <p>Response (201 Created):</p> <pre><code>{\n \"id\": \"clx1234567890\",\n \"status\": \"PENDING\",\n \"verificationSent\": true\n}\n</code></pre> <p>Verification Email Flow:</p> <p>If <code>sendVerification=true</code> and <code>representativeEmail</code> is provided, an email is sent to the representative with:</p> <ul> <li>Verify Link: Marks response as APPROVED and verified</li> <li>Report Link: Marks response as REJECTED (representative disputes it)</li> <li>30-day expiry: Verification token expires after 30 days</li> </ul> <p>Example Verification Email:</p> <pre><code>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</code></pre> <p>Error Responses:</p> <ul> <li><code>400 Bad Request</code>: Campaign not active, response wall disabled, or validation error</li> <li><code>404 Not Found</code>: Campaign not found</li> <li><code>429 Too Many Requests</code>: Rate limit exceeded (10/min)</li> </ul> <p>Campaign Requirements:</p> <ul> <li>Campaign must have <code>status=ACTIVE</code></li> <li>Campaign must have <code>showResponseWall=true</code></li> </ul>"},{"location":"v2/backend/modules/responses/#get-apicampaignsslugresponses","title":"GET /api/campaigns/:slug/responses","text":"<p>List approved responses for a campaign.</p> <p>Path Parameters:</p> <ul> <li><code>slug</code> (string): Campaign slug</li> </ul> <p>Query Parameters:</p> 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: <code>recent</code>, <code>upvotes</code>, <code>verified</code> level GovernmentLevel No - Filter by government level <p>Example Request:</p> <pre><code># 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</code></pre> <p>Response (200 OK):</p> <pre><code>{\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</code></pre> <p>Response Fields:</p> <ul> <li>Only <code>APPROVED</code> responses are returned</li> <li><code>submittedByName</code> is null if <code>isAnonymous=true</code></li> <li><code>submittedByEmail</code> never exposed on public routes</li> <li><code>representativeEmail</code> never exposed on public routes</li> </ul> <p>Sorting:</p> <pre><code>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</code></pre>"},{"location":"v2/backend/modules/responses/#get-apicampaignsslugresponse-stats","title":"GET /api/campaigns/:slug/response-stats","text":"<p>Get aggregate statistics for campaign responses.</p> <p>Path Parameters:</p> <ul> <li><code>slug</code> (string): Campaign slug</li> </ul> <p>Example Request:</p> <pre><code>curl \"http://api.cmlite.org/api/campaigns/climate-action-now/response-stats\"\n</code></pre> <p>Response (200 OK):</p> <pre><code>{\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</code></pre> <p>Field Descriptions:</p> <ul> <li><code>total</code>: Total APPROVED responses for campaign</li> <li><code>verified</code>: Count of APPROVED responses with <code>isVerified=true</code></li> <li><code>totalUpvotes</code>: Sum of all <code>upvoteCount</code> values</li> <li><code>byLevel</code>: Breakdown by government level</li> </ul>"},{"location":"v2/backend/modules/responses/#post-apiresponsesidupvote","title":"POST /api/responses/:id/upvote","text":"<p>Upvote a response.</p> <p>Authentication: Optional (supports both logged-in and anonymous users)</p> <p>Path Parameters:</p> <ul> <li><code>id</code> (string): Response ID</li> </ul> <p>Example Request:</p> <pre><code># 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</code></pre> <p>Response (200 OK):</p> <pre><code>{\n \"success\": true\n}\n</code></pre> <p>Response (200 OK, Already Upvoted):</p> <pre><code>{\n \"success\": false,\n \"alreadyUpvoted\": true\n}\n</code></pre> <p>Upvoting Logic:</p> <ol> <li>Verify response exists and is APPROVED</li> <li>Create <code>ResponseUpvote</code> record:</li> <li>Logged-in: <code>userId</code> + <code>responseId</code> (allows upvoting from multiple IPs)</li> <li>Anonymous: <code>upvotedIp</code> + <code>responseId</code> (prevents duplicate upvotes from same IP)</li> <li>Increment <code>upvoteCount</code> on response</li> <li>If duplicate (Prisma P2002 error), return <code>alreadyUpvoted: true</code></li> </ol> <p>Error Responses:</p> <ul> <li><code>400 Bad Request</code>: Response is not approved</li> <li><code>404 Not Found</code>: Response not found</li> </ul>"},{"location":"v2/backend/modules/responses/#delete-apiresponsesidupvote","title":"DELETE /api/responses/:id/upvote","text":"<p>Remove upvote from a response.</p> <p>Authentication: Optional (supports both logged-in and anonymous users)</p> <p>Path Parameters:</p> <ul> <li><code>id</code> (string): Response ID</li> </ul> <p>Example Request:</p> <pre><code># 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</code></pre> <p>Response (200 OK):</p> <pre><code>{\n \"success\": true\n}\n</code></pre> <p>Response (200 OK, Not Upvoted):</p> <pre><code>{\n \"success\": false\n}\n</code></pre> <p>Logic:</p> <ol> <li>Delete <code>ResponseUpvote</code> record matching <code>responseId</code> + <code>userId</code> (or <code>upvotedIp</code> if anonymous)</li> <li>Decrement <code>upvoteCount</code> if deleted</li> <li>Return <code>success: false</code> if no upvote record found</li> </ol>"},{"location":"v2/backend/modules/responses/#get-apiresponsesidverifytoken","title":"GET /api/responses/:id/verify/:token","text":"<p>Verify a response (representative confirms authenticity). Returns HTML result page.</p> <p>Path Parameters:</p> <ul> <li><code>id</code> (string): Response ID</li> <li><code>token</code> (string): Verification token (64-char hex)</li> </ul> <p>Example URL:</p> <pre><code>https://api.cmlite.org/api/responses/clx1234567890/verify/abc123...\n</code></pre> <p>Response (200 OK, Success):</p> <p>Returns HTML page with success message:</p> <pre><code><!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</code></pre> <p>Response (200 OK, Failed):</p> <p>Returns HTML page with error message:</p> <ul> <li><code>reason: \"Invalid verification link\"</code> \u2014 Token doesn't match</li> <li><code>reason: \"Verification link has expired\"</code> \u2014 More than 30 days since sent</li> </ul> <p>Database Changes on Success:</p> <pre><code>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</code></pre> <p>Expiry Logic:</p> <pre><code>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</code></pre>"},{"location":"v2/backend/modules/responses/#get-apiresponsesidreporttoken","title":"GET /api/responses/:id/report/:token","text":"<p>Report a response as invalid (representative disputes it). Returns HTML result page.</p> <p>Path Parameters:</p> <ul> <li><code>id</code> (string): Response ID</li> <li><code>token</code> (string): Verification token (same token as verify link)</li> </ul> <p>Example URL:</p> <pre><code>https://api.cmlite.org/api/responses/clx1234567890/report/abc123...\n</code></pre> <p>Response (200 OK, Success):</p> <p>Returns HTML page with confirmation:</p> <pre><code><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</code></pre> <p>Database Changes on Success:</p> <pre><code>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</code></pre> <p>Use Cases:</p> <ul> <li>Representative never sent the response (fake submission)</li> <li>Response text is inaccurate or fabricated</li> <li>Response was sent by someone else impersonating the representative</li> </ul>"},{"location":"v2/backend/modules/responses/#admin-endpoint-details","title":"Admin Endpoint Details","text":""},{"location":"v2/backend/modules/responses/#get-apiresponses","title":"GET /api/responses","text":"<p>List all responses with admin filters.</p> <p>Authentication: Required (Admin roles)</p> <p>Query Parameters:</p> Parameter Type Required Default Description page number No 1 Page number limit number No 20 Results per page (max 100) status ResponseStatus No - Filter by status (PENDING, APPROVED, REJECTED) campaignId string No - Filter by campaign ID search string No - Search name, response text, or submitter <p>Example Request:</p> <pre><code># 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</code></pre> <p>Response (200 OK):</p> <pre><code>{\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</code></pre> <p>Differences from Public Route:</p> <ul> <li>Includes <code>representativeEmail</code>, <code>submittedByEmail</code> (sensitive fields)</li> <li>Returns all statuses (not just APPROVED)</li> <li>Includes <code>campaign</code> relation</li> <li>Search across name, response text, submitter name</li> </ul> <p>Search Logic:</p> <pre><code>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</code></pre>"},{"location":"v2/backend/modules/responses/#patch-apiresponsesidstatus","title":"PATCH /api/responses/:id/status","text":"<p>Update response status (approve or reject).</p> <p>Authentication: Required (Admin roles)</p> <p>Path Parameters:</p> <ul> <li><code>id</code> (string): Response ID</li> </ul> <p>Request Body:</p> <pre><code>{\n \"status\": \"APPROVED\"\n}\n</code></pre> <p>Valid Statuses: <code>PENDING</code>, <code>APPROVED</code>, <code>REJECTED</code></p> <p>Example Request:</p> <pre><code># 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</code></pre> <p>Response (200 OK):</p> <p>Returns updated response object (same format as GET).</p> <p>Error Responses:</p> <ul> <li><code>404 Not Found</code>: Response not found</li> </ul> <p>Use Cases:</p> <ul> <li>Manual moderation: approve legitimate responses, reject spam</li> <li>Bulk approval after reviewing pending queue</li> <li>Reject disputed responses without representative verification</li> </ul>"},{"location":"v2/backend/modules/responses/#post-apiresponsesidresend-verification","title":"POST /api/responses/:id/resend-verification","text":"<p>Resend verification email to representative.</p> <p>Authentication: Required (Admin roles)</p> <p>Path Parameters:</p> <ul> <li><code>id</code> (string): Response ID</li> </ul> <p>Example Request:</p> <pre><code>curl -X POST -H \"Authorization: Bearer <token>\" \\\n \"http://api.cmlite.org/api/responses/clx1234567890/resend-verification\"\n</code></pre> <p>Response (200 OK):</p> <pre><code>{\n \"success\": true\n}\n</code></pre> <p>Error Responses:</p> <ul> <li><code>400 Bad Request</code>: No representative email on record</li> <li><code>404 Not Found</code>: Response not found</li> </ul> <p>Logic:</p> <ol> <li>Retrieve existing response</li> <li>Regenerate verification token (or reuse existing)</li> <li>Update <code>verificationToken</code> and <code>verificationSentAt</code> in database</li> <li>Send verification email to <code>representativeEmail</code></li> </ol> <p>Use Cases:</p> <ul> <li>Verification email wasn't delivered</li> <li>Representative lost the original email</li> <li>Token expired (more than 30 days old)</li> </ul>"},{"location":"v2/backend/modules/responses/#delete-apiresponsesid","title":"DELETE /api/responses/:id","text":"<p>Delete a response permanently.</p> <p>Authentication: Required (Admin roles)</p> <p>Path Parameters:</p> <ul> <li><code>id</code> (string): Response ID</li> </ul> <p>Example Request:</p> <pre><code>curl -X DELETE -H \"Authorization: Bearer <token>\" \\\n \"http://api.cmlite.org/api/responses/clx1234567890\"\n</code></pre> <p>Response (204 No Content):</p> <p>No response body.</p> <p>Error Responses:</p> <ul> <li><code>404 Not Found</code>: Response not found</li> </ul> <p>Cascading Deletes:</p> <ul> <li>All <code>ResponseUpvote</code> records for this response (via Prisma cascade)</li> </ul>"},{"location":"v2/backend/modules/responses/#service-functions","title":"Service Functions","text":""},{"location":"v2/backend/modules/responses/#responsesservicesubmitresponseslug-data-senderip","title":"responsesService.submitResponse(slug, data, senderIp)","text":"<p>Submit new response to campaign.</p> <p>Parameters:</p> <ul> <li><code>slug</code> (string): Campaign slug</li> <li><code>data</code> (SubmitResponseInput): Response data</li> <li><code>senderIp</code> (string, optional): Submitter's IP address</li> </ul> <p>Returns:</p> <pre><code>{\n id: string;\n status: ResponseStatus;\n verificationSent: boolean;\n}\n</code></pre> <p>Validation:</p> <ul> <li>Campaign must exist and be ACTIVE</li> <li>Campaign must have <code>showResponseWall=true</code></li> <li>If <code>sendVerification=true</code>, <code>representativeEmail</code> is required</li> </ul> <p>Verification Token:</p> <pre><code>let verificationToken: string | null = null;\n\nif (data.sendVerification && data.representativeEmail) {\n verificationToken = randomBytes(32).toString('hex'); // 64-char hex string\n}\n</code></pre> <p>Metrics:</p> <p>Calls <code>recordResponseSubmission()</code> to increment Prometheus counter.</p>"},{"location":"v2/backend/modules/responses/#responsesservicelistapprovedslug-filters","title":"responsesService.listApproved(slug, filters)","text":"<p>List approved responses for campaign with sorting.</p> <p>Parameters:</p> <pre><code>{\n page: number;\n limit: number;\n sort: 'recent' | 'upvotes' | 'verified';\n level?: GovernmentLevel;\n}\n</code></pre> <p>Returns:</p> <pre><code>{\n responses: Response[];\n pagination: Pagination;\n}\n</code></pre>"},{"location":"v2/backend/modules/responses/#responsesservicegetstatsslug","title":"responsesService.getStats(slug)","text":"<p>Get aggregate statistics for campaign responses.</p> <p>Returns:</p> <pre><code>{\n total: number;\n verified: number;\n totalUpvotes: number;\n byLevel: Record<string, number>;\n}\n</code></pre>"},{"location":"v2/backend/modules/responses/#responsesserviceupvoteresponseid-userip-userid","title":"responsesService.upvote(responseId, userIp, userId)","text":"<p>Upvote a response.</p> <p>Parameters:</p> <ul> <li><code>responseId</code> (string): Response ID</li> <li><code>userIp</code> (string, optional): User's IP address</li> <li><code>userId</code> (string, optional): User ID (if logged in)</li> </ul> <p>Returns:</p> <pre><code>{\n success: boolean;\n alreadyUpvoted?: boolean; // True if duplicate upvote attempt\n}\n</code></pre> <p>Logic:</p> <pre><code>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</code></pre>"},{"location":"v2/backend/modules/responses/#responsesserviceremoveupvoteresponseid-userip-userid","title":"responsesService.removeUpvote(responseId, userIp, userId)","text":"<p>Remove upvote from response.</p> <p>Parameters:</p> <ul> <li><code>responseId</code> (string): Response ID</li> <li><code>userIp</code> (string, optional): User's IP address</li> <li><code>userId</code> (string, optional): User ID (if logged in)</li> </ul> <p>Returns:</p> <pre><code>{\n success: boolean; // True if upvote was found and removed\n}\n</code></pre>"},{"location":"v2/backend/modules/responses/#responsesserviceverifyresponseid-token","title":"responsesService.verify(responseId, token)","text":"<p>Verify a response via email link.</p> <p>Parameters:</p> <ul> <li><code>responseId</code> (string): Response ID</li> <li><code>token</code> (string): Verification token</li> </ul> <p>Returns:</p> <pre><code>{\n success: boolean;\n campaignTitle?: string; // On success\n reason?: string; // On failure\n}\n</code></pre> <p>Failure Reasons:</p> <ul> <li><code>\"Invalid verification link\"</code> \u2014 Token doesn't match</li> <li><code>\"Verification link has expired\"</code> \u2014 More than 30 days old</li> </ul>"},{"location":"v2/backend/modules/responses/#responsesservicereportresponseid-token","title":"responsesService.report(responseId, token)","text":"<p>Report a response as invalid via email link.</p> <p>Parameters:</p> <ul> <li><code>responseId</code> (string): Response ID</li> <li><code>token</code> (string): Verification token (same as verify link)</li> </ul> <p>Returns:</p> <pre><code>{\n success: boolean;\n campaignTitle?: string;\n reason?: string;\n}\n</code></pre> <p>Database Changes:</p> <ul> <li>Sets <code>status=REJECTED</code></li> <li>Sets <code>isVerified=false</code></li> <li>Sets <code>verifiedBy</code> to \"Disputed by {email}\"</li> </ul>"},{"location":"v2/backend/modules/responses/#responsesservicefindallfilters-admin","title":"responsesService.findAll(filters) (Admin)","text":"<p>List all responses with admin filters.</p> <p>Parameters:</p> <pre><code>{\n page: number;\n limit: number;\n status?: ResponseStatus;\n campaignId?: string;\n search?: string;\n}\n</code></pre> <p>Returns:</p> <pre><code>{\n responses: Response[];\n pagination: Pagination;\n}\n</code></pre>"},{"location":"v2/backend/modules/responses/#responsesserviceupdatestatusid-data-admin","title":"responsesService.updateStatus(id, data) (Admin)","text":"<p>Update response status.</p> <p>Throws: <code>AppError(404)</code> if not found</p>"},{"location":"v2/backend/modules/responses/#responsesservicedeleteresponseid-admin","title":"responsesService.deleteResponse(id) (Admin)","text":"<p>Delete response permanently.</p> <p>Throws: <code>AppError(404)</code> if not found</p>"},{"location":"v2/backend/modules/responses/#responsesserviceresendverificationid-admin","title":"responsesService.resendVerification(id) (Admin)","text":"<p>Resend verification email to representative.</p> <p>Throws:</p> <ul> <li><code>AppError(404)</code> if response not found</li> <li><code>AppError(400)</code> if no representative email on record</li> </ul>"},{"location":"v2/backend/modules/responses/#validation-schemas","title":"Validation Schemas","text":""},{"location":"v2/backend/modules/responses/#submit-response-schema","title":"Submit Response Schema","text":"<pre><code>export const submitResponseSchema = z.object({\n representativeName: z.string().min(1, 'Representative name is required'),\n representativeLevel: z.nativeEnum(GovernmentLevel),\n responseType: z.nativeEnum(ResponseType),\n responseText: z.string().min(1, 'Response text is required'),\n representativeTitle: z.string().optional(),\n representativeEmail: z.string().email().optional(),\n userComment: z.string().optional(),\n submittedByName: z.string().optional(),\n submittedByEmail: z.string().email().optional(),\n isAnonymous: z.boolean().optional().default(false),\n sendVerification: z.boolean().optional().default(false),\n});\n</code></pre>"},{"location":"v2/backend/modules/responses/#list-public-responses-schema","title":"List Public Responses Schema","text":"<pre><code>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</code></pre>"},{"location":"v2/backend/modules/responses/#list-admin-responses-schema","title":"List Admin Responses Schema","text":"<pre><code>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</code></pre>"},{"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":"<pre><code>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</code></pre>"},{"location":"v2/backend/modules/responses/#public-upvote-response","title":"Public: Upvote Response","text":"<pre><code>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</code></pre>"},{"location":"v2/backend/modules/responses/#admin-approve-response","title":"Admin: Approve Response","text":"<pre><code>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</code></pre>"},{"location":"v2/backend/modules/responses/#admin-resend-verification","title":"Admin: Resend Verification","text":"<pre><code>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</code></pre>"},{"location":"v2/backend/modules/responses/#frontend-integration","title":"Frontend Integration","text":""},{"location":"v2/backend/modules/responses/#responsespage-admin","title":"ResponsesPage (Admin)","text":"<p>The ResponsesPage component (<code>admin/src/pages/ResponsesPage.tsx</code>) provides:</p> <ul> <li>Response table with pagination</li> <li>Status filter (PENDING, APPROVED, REJECTED)</li> <li>Campaign filter</li> <li>Search by name, response text, or submitter</li> <li>Response detail drawer (shows full response + verification status)</li> <li>Status update actions (approve, reject)</li> <li>Resend verification button</li> <li>Delete response action</li> <li>Verification status badges (verified/unverified)</li> </ul>"},{"location":"v2/backend/modules/responses/#responsewallpage-public","title":"ResponseWallPage (Public)","text":"<p>The ResponseWallPage component (<code>admin/src/pages/public/ResponseWallPage.tsx</code>) provides:</p> <ul> <li>Response card grid layout</li> <li>Sort controls (recent, upvotes, verified)</li> <li>Government level filter</li> <li>Upvote buttons (IP-based for anonymous)</li> <li>Verified badges</li> <li>Submit response modal (opens from campaign page)</li> <li>Response statistics (total, verified, upvotes)</li> <li>Anonymous submission toggle</li> </ul>"},{"location":"v2/backend/modules/responses/#performance-considerations","title":"Performance Considerations","text":"<p>Upvote Constraints:</p> <ul> <li>Unique constraints prevent duplicate upvotes at database level</li> <li>No need for application-level deduplication logic</li> <li>Concurrent upvote attempts return <code>alreadyUpvoted: true</code></li> </ul> <p>Indexing:</p> <ul> <li><code>@@index([campaignId])</code> \u2014 Fast filtering by campaign</li> <li><code>@@index([campaignSlug])</code> \u2014 Fast public lookup</li> <li><code>@@index([status])</code> \u2014 Fast admin filtering</li> </ul> <p>Pagination:</p> <ul> <li>Max 100 results per page prevents excessive data transfer</li> <li>Default 20 results balances performance and UX</li> </ul>"},{"location":"v2/backend/modules/responses/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/backend/modules/responses/#issue-verification-email-not-delivered","title":"Issue: Verification email not delivered","text":"<p>Cause: SMTP configuration issue or email blocked by spam filter</p> <p>Solution:</p> <ul> <li>Check <code>EMAIL_TEST_MODE=true</code> in <code>.env</code> (emails go to MailHog)</li> <li>Verify SMTP credentials in site settings</li> <li>Check spam folder on representative's email</li> <li>Admin: Use \"Resend Verification\" button</li> </ul>"},{"location":"v2/backend/modules/responses/#issue-verification-link-expired","title":"Issue: Verification link expired","text":"<p>Cause: More than 30 days since verification email sent</p> <p>Solution:</p> <ul> <li>Admin: Use \"Resend Verification\" to generate new token</li> <li>New verification email sent with fresh 30-day expiry</li> </ul>"},{"location":"v2/backend/modules/responses/#issue-cant-upvote-response","title":"Issue: Can't upvote response","text":"<p>Cause: Already upvoted, or response not approved</p> <p>Solution:</p> <ul> <li>Check <code>alreadyUpvoted: true</code> in response</li> <li>Remove existing upvote first (DELETE endpoint)</li> <li>Verify response has <code>status=APPROVED</code></li> </ul>"},{"location":"v2/backend/modules/responses/#issue-response-not-appearing-on-public-wall","title":"Issue: Response not appearing on public wall","text":"<p>Cause: Status is PENDING or REJECTED</p> <p>Solution:</p> <ul> <li>Admin: Check response status in ResponsesPage</li> <li>Admin: Approve response manually if legitimate</li> <li>If verification email sent, representative must click verify link</li> </ul>"},{"location":"v2/backend/modules/responses/#related-documentation","title":"Related Documentation","text":"<ul> <li>Campaigns Module - Campaign configuration with response wall flag</li> <li>Email Service - Verification email sending</li> <li>Frontend: ResponsesPage - Admin moderation UI</li> <li>Frontend: ResponseWallPage - Public response wall</li> <li>Frontend: Public Campaign Page - Submit response integration</li> <li>API Reference: Responses - Complete endpoint reference</li> <li>Feature: Response Wall - Response wall feature guide</li> </ul>"},{"location":"v2/backend/modules/settings/","title":"Settings Module","text":""},{"location":"v2/backend/modules/settings/#overview","title":"Overview","text":"<p>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.</p> <p>Key Features:</p> <ul> <li>Singleton pattern (one settings record per installation)</li> <li>Field-level encryption (SMTP passwords encrypted at rest)</li> <li>Public vs. admin endpoints (strips credentials from public responses)</li> <li>SMTP configuration with test connection/send</li> <li>Email service integration (auto-rebuild transporter on changes)</li> <li>Organization branding (name, logo, favicon)</li> <li>Theme customization (admin + public color schemes)</li> <li>Feature toggles (Influence, Map, Newsletter, Landing Pages)</li> </ul>"},{"location":"v2/backend/modules/settings/#file-paths","title":"File Paths","text":"File Purpose <code>api/src/modules/settings/settings.routes.ts</code> Express router with 5 endpoints <code>api/src/modules/settings/settings.service.ts</code> Settings business logic with encryption <code>api/src/modules/settings/settings.schemas.ts</code> Zod validation schema <code>api/src/utils/crypto.ts</code> AES-256-GCM encryption/decryption"},{"location":"v2/backend/modules/settings/#database-model","title":"Database Model","text":"<pre><code>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</code></pre> <p>Singleton Pattern:</p> <ul> <li>Only one <code>SiteSettings</code> record exists in the database</li> <li>Auto-created with defaults on first access if missing</li> <li>All updates modify the existing record</li> </ul> <p>Encryption:</p> <ul> <li><code>smtpPass</code> encrypted at rest with AES-256-GCM</li> <li>Uses <code>ENCRYPTION_KEY</code> environment variable (must NOT reuse JWT secrets)</li> <li>Decrypted on read, re-encrypted on write</li> </ul>"},{"location":"v2/backend/modules/settings/#api-endpoints","title":"API Endpoints","text":"Method Path Auth Description GET <code>/api/settings</code> None Get public settings (strips SMTP credentials) GET <code>/api/settings/admin</code> SUPER_ADMIN Get full settings (includes SMTP credentials) PUT <code>/api/settings</code> SUPER_ADMIN Update settings POST <code>/api/settings/email/test-connection</code> SUPER_ADMIN Test SMTP connection POST <code>/api/settings/email/test-send</code> 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":"<p>Get public-safe settings (no authentication required). Used by login page and public pages.</p> <p>Security: Strips SMTP credentials before returning: - <code>smtpHost</code> - <code>smtpPort</code> - <code>smtpUser</code> - <code>smtpPass</code> - <code>smtpFromAddress</code> - <code>testEmailRecipient</code></p> <p>Example Request:</p> <pre><code>curl http://api.cmlite.org/api/settings\n</code></pre> <p>Response (200 OK):</p> <pre><code>{\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</code></pre> <p>Implementation:</p> <pre><code>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</code></pre> <p>Service Logic:</p> <pre><code>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</code></pre>"},{"location":"v2/backend/modules/settings/#get-apisettingsadmin","title":"GET /api/settings/admin","text":"<p>Get full settings including SMTP credentials (SUPER_ADMIN only).</p> <p>Request Headers:</p> <pre><code>Authorization: Bearer <access_token>\n</code></pre> <p>Example Request:</p> <pre><code>curl -H \"Authorization: Bearer <token>\" \\\n http://api.cmlite.org/api/settings/admin\n</code></pre> <p>Response (200 OK):</p> <pre><code>{\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</code></pre> <p>Error Responses:</p> <ul> <li><code>401 Unauthorized</code>: Missing or invalid access token</li> <li><code>403 Forbidden</code>: Non-SUPER_ADMIN user</li> </ul> <p>Implementation:</p> <pre><code>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</code></pre> <p>Decryption:</p> <pre><code>/** 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</code></pre>"},{"location":"v2/backend/modules/settings/#put-apisettings","title":"PUT /api/settings","text":"<p>Update site settings (SUPER_ADMIN only). Automatically rebuilds email transporter if SMTP fields change.</p> <p>Request Headers:</p> <pre><code>Authorization: Bearer <access_token>\nContent-Type: application/json\n</code></pre> <p>Request Body (Partial Update):</p> <pre><code>{\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</code></pre> <p>All fields are optional (partial updates supported).</p> <p>Response (200 OK):</p> <p>Returns updated settings (same format as GET /api/settings/admin).</p> <p>Error Responses:</p> <ul> <li><code>401 Unauthorized</code>: Missing or invalid access token</li> <li><code>403 Forbidden</code>: Non-SUPER_ADMIN user</li> <li><code>400 Bad Request</code>: Invalid field values</li> </ul> <p>Implementation:</p> <pre><code>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</code></pre> <p>Encryption on Write:</p> <pre><code>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</code></pre> <p>Email Transporter Rebuild:</p> <p>When SMTP settings change, the email service transporter is automatically rebuilt:</p> <pre><code>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</code></pre>"},{"location":"v2/backend/modules/settings/#post-apisettingsemailtest-connection","title":"POST /api/settings/email/test-connection","text":"<p>Test SMTP connection (SUPER_ADMIN only).</p> <p>Request Headers:</p> <pre><code>Authorization: Bearer <access_token>\n</code></pre> <p>Example Request:</p> <pre><code>curl -X POST \\\n -H \"Authorization: Bearer <token>\" \\\n http://api.cmlite.org/api/settings/email/test-connection\n</code></pre> <p>Response (200 OK):</p> <pre><code>{\n \"success\": true,\n \"message\": \"SMTP connection verified\"\n}\n</code></pre> <p>Response (Failure):</p> <pre><code>{\n \"success\": false,\n \"message\": \"SMTP connection failed\"\n}\n</code></pre> <p>Implementation:</p> <pre><code>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</code></pre>"},{"location":"v2/backend/modules/settings/#post-apisettingsemailtest-send","title":"POST /api/settings/email/test-send","text":"<p>Send test email to verify SMTP configuration (SUPER_ADMIN only).</p> <p>Request Headers:</p> <pre><code>Authorization: Bearer <access_token>\nContent-Type: application/json\n</code></pre> <p>Request Body (Optional):</p> <pre><code>{\n \"to\": \"test@example.com\"\n}\n</code></pre> <p>If <code>to</code> is not provided, uses <code>testEmailRecipient</code> from settings or defaults to <code>admin@cmlite.org</code>.</p> <p>Example Request:</p> <pre><code>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</code></pre> <p>Response (200 OK):</p> <pre><code>{\n \"success\": true,\n \"messageId\": \"<20260211120000.1.abcd1234@cmlite.org>\",\n \"testMode\": false,\n \"recipient\": \"test@example.com\"\n}\n</code></pre> <p>Response (Test Mode):</p> <pre><code>{\n \"success\": true,\n \"messageId\": \"test-mode-1234567890\",\n \"testMode\": true,\n \"recipient\": \"test@example.com\"\n}\n</code></pre> <p>Implementation:</p> <pre><code>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</code></pre> <p>Test Mode:</p> <p>If <code>emailTestMode</code> is <code>true</code>, emails are sent to MailHog instead of actual SMTP server:</p> <ul> <li>Development: MailHog captures emails at http://localhost:8025</li> <li>Production: Should set <code>emailTestMode: false</code> to use real SMTP</li> </ul>"},{"location":"v2/backend/modules/settings/#service-functions","title":"Service Functions","text":""},{"location":"v2/backend/modules/settings/#sitesettingsserviceget","title":"siteSettingsService.get()","text":"<p>Purpose: Get full settings with decrypted SMTP password (admin use).</p> <p>Auto-Creation:</p> <pre><code>let settings = await prisma.siteSettings.findFirst();\nif (!settings) {\n settings = await prisma.siteSettings.create({ data: {} });\n}\nreturn decryptSettings(settings);\n</code></pre>"},{"location":"v2/backend/modules/settings/#sitesettingsservicegetpublic","title":"siteSettingsService.getPublic()","text":"<p>Purpose: Get settings without sensitive SMTP fields (public use).</p> <p>Stripped Fields:</p> <ul> <li><code>smtpHost</code></li> <li><code>smtpPort</code></li> <li><code>smtpUser</code></li> <li><code>smtpPass</code></li> <li><code>smtpFromAddress</code></li> <li><code>testEmailRecipient</code></li> </ul>"},{"location":"v2/backend/modules/settings/#sitesettingsserviceupdatedata","title":"siteSettingsService.update(data)","text":"<p>Purpose: Update settings with encryption for sensitive fields.</p> <p>Encryption:</p> <pre><code>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</code></pre> <p>Upsert Logic:</p> <pre><code>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</code></pre>"},{"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":"<pre><code>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</code></pre>"},{"location":"v2/backend/modules/settings/#admin-update-settings","title":"Admin: Update Settings","text":"<pre><code>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</code></pre>"},{"location":"v2/backend/modules/settings/#admin-test-smtp-connection","title":"Admin: Test SMTP Connection","text":"<pre><code>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</code></pre>"},{"location":"v2/backend/modules/settings/#admin-send-test-email","title":"Admin: Send Test Email","text":"<pre><code>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</code></pre>"},{"location":"v2/backend/modules/settings/#validation-schema","title":"Validation Schema","text":"<pre><code>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</code></pre>"},{"location":"v2/backend/modules/settings/#encryption","title":"Encryption","text":""},{"location":"v2/backend/modules/settings/#aes-256-gcm-encryption","title":"AES-256-GCM Encryption","text":"<p>The <code>smtpPass</code> field is encrypted at rest using AES-256-GCM (authenticated encryption).</p> <p>Environment Configuration:</p> <pre><code>ENCRYPTION_KEY=<32-byte-hex> # Must NOT reuse JWT secrets\n</code></pre> <p>Generate Encryption Key:</p> <pre><code>openssl rand -hex 32\n</code></pre> <p>Encryption Utility:</p> <pre><code>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</code></pre>"},{"location":"v2/backend/modules/settings/#feature-toggles","title":"Feature Toggles","text":"Toggle Default Description <code>enableInfluence</code> <code>true</code> Advocacy campaigns + response wall <code>enableMap</code> <code>true</code> Location mapping + canvassing <code>enableNewsletter</code> <code>false</code> Listmonk integration <code>enableLandingPages</code> <code>true</code> GrapesJS page builder <p>Frontend Usage:</p> <pre><code>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</code></pre>"},{"location":"v2/backend/modules/settings/#environment-configuration","title":"Environment Configuration","text":"<p>Required environment variables:</p> <pre><code># 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</code></pre>"},{"location":"v2/backend/modules/settings/#related-documentation","title":"Related Documentation","text":"<ul> <li>Email Service - SMTP email sending</li> <li>Crypto Utilities - Encryption/decryption</li> <li>Frontend: SettingsPage - Settings management UI</li> <li>API Reference: Settings - Complete endpoint reference</li> <li>User Guide: Admin - Configuring site settings</li> </ul>"},{"location":"v2/backend/modules/shifts/","title":"Shifts Module","text":""},{"location":"v2/backend/modules/shifts/#overview","title":"Overview","text":"<p>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.</p> <p>Key Features:</p> <ul> <li>Full shift CRUD with pagination, search, and filtering</li> <li>Automatic status management (OPEN \u2192 FULL based on capacity)</li> <li>Cut association for canvassing shifts (optional)</li> <li>Three signup sources: admin-added, authenticated user, public</li> <li>Temporary user creation for public signups (auto-expires after shift date)</li> <li>Email confirmation system with readable passwords for new users</li> <li>Capacity tracking (currentVolunteers / maxVolunteers)</li> <li>Cancellation system with capacity recalculation</li> <li>Email all volunteers functionality</li> <li>Rate limiting on signup endpoints (5/min per IP)</li> <li>Prometheus metrics tracking (<code>cm_shift_signups_total</code>)</li> </ul>"},{"location":"v2/backend/modules/shifts/#file-paths","title":"File Paths","text":"File Purpose <code>api/src/modules/map/shifts/shifts.routes.ts</code> 3 routers: admin, volunteer, public (242 lines) <code>api/src/modules/map/shifts/shifts.service.ts</code> Shift business logic with signup flows (754 lines) <code>api/src/modules/map/shifts/shifts.schemas.ts</code> Zod validation schemas (55 lines)"},{"location":"v2/backend/modules/shifts/#database-models","title":"Database Models","text":"<pre><code>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</code></pre> <p>Key Relationships:</p> <ul> <li>Shift \u2192 ShiftSignup: One-to-many (cascade delete)</li> <li>Shift \u2192 Cut: Optional many-to-one (cut assignment for canvassing, set null on delete)</li> <li>Shift \u2192 CanvassSession/CanvassVisit: One-to-many (canvassing data linked to shifts)</li> <li>ShiftSignup \u2192 User: Optional many-to-one (set null on user delete, preserves signup record)</li> </ul> <p>Unique Constraints:</p> <ul> <li><code>[shiftId, userEmail]</code> \u2014 One signup per email per shift (allows re-activation of cancelled signups)</li> </ul>"},{"location":"v2/backend/modules/shifts/#api-endpoints","title":"API Endpoints","text":""},{"location":"v2/backend/modules/shifts/#admin-endpoints-authentication-required","title":"Admin Endpoints (Authentication Required)","text":"Method Path Auth Description GET <code>/api/map/shifts</code> MAP_ADMIN List paginated shifts GET <code>/api/map/shifts/stats</code> MAP_ADMIN Shift statistics GET <code>/api/map/shifts/:id</code> MAP_ADMIN Get shift with signups POST <code>/api/map/shifts</code> MAP_ADMIN Create shift PUT <code>/api/map/shifts/:id</code> MAP_ADMIN Update shift DELETE <code>/api/map/shifts/:id</code> MAP_ADMIN Delete shift POST <code>/api/map/shifts/:id/signups</code> MAP_ADMIN Add volunteer signup DELETE <code>/api/map/shifts/:id/signups/:signupId</code> MAP_ADMIN Remove signup POST <code>/api/map/shifts/:id/email-details</code> MAP_ADMIN Email all volunteers <p>Admin Roles: <code>SUPER_ADMIN</code>, <code>MAP_ADMIN</code></p>"},{"location":"v2/backend/modules/shifts/#volunteer-endpoints-authentication-required","title":"Volunteer Endpoints (Authentication Required)","text":"Method Path Auth Description GET <code>/api/map/shifts/volunteer/upcoming</code> Any logged-in Upcoming shifts with signup status GET <code>/api/map/shifts/volunteer/my-signups</code> Any logged-in Own confirmed signups POST <code>/api/map/shifts/volunteer/:id/signup</code> Any logged-in Sign up for shift DELETE <code>/api/map/shifts/volunteer/:id/signup</code> 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 <code>/api/map/shifts/public</code> None List public upcoming shifts POST <code>/api/map/shifts/public/:id/signup</code> 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":"<p>List shifts with pagination, search, and filtering.</p> <p>Query Parameters:</p> Parameter Type Required Default Description page number No 1 Page number limit number No 20 Results per page (max 100) search string No - Search title or location status ShiftStatus No - Filter by status upcoming boolean No - Filter to shifts with date >= today sortBy enum No <code>date</code> Sort field: <code>date</code>, <code>createdAt</code>, <code>title</code> sortOrder enum No <code>desc</code> Sort direction: <code>asc</code>, <code>desc</code> <p>Example Request:</p> <pre><code>curl -H \"Authorization: Bearer <token>\" \\\n \"http://api.cmlite.org/api/map/shifts?upcoming=true&status=OPEN&page=1&limit=10\"\n</code></pre> <p>Response (200 OK):</p> <pre><code>{\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</code></pre>"},{"location":"v2/backend/modules/shifts/#get-apimapshiftsstats","title":"GET /api/map/shifts/stats","text":"<p>Get shift statistics.</p> <p>Example Request:</p> <pre><code>curl -H \"Authorization: Bearer <token>\" \\\n \"http://api.cmlite.org/api/map/shifts/stats\"\n</code></pre> <p>Response (200 OK):</p> <pre><code>{\n \"total\": 45,\n \"open\": 12,\n \"full\": 3,\n \"cancelled\": 2,\n \"upcoming\": 15,\n \"totalSignups\": 287\n}\n</code></pre>"},{"location":"v2/backend/modules/shifts/#get-apimapshiftsid","title":"GET /api/map/shifts/:id","text":"<p>Get single shift with signups list.</p> <p>Path Parameters:</p> <ul> <li><code>id</code> (string): Shift ID</li> </ul> <p>Example Request:</p> <pre><code>curl -H \"Authorization: Bearer <token>\" \\\n \"http://api.cmlite.org/api/map/shifts/clx1234567890\"\n</code></pre> <p>Response (200 OK):</p> <pre><code>{\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</code></pre> <p>Error Responses:</p> <ul> <li><code>404 Not Found</code>: Shift not found</li> </ul>"},{"location":"v2/backend/modules/shifts/#post-apimapshifts","title":"POST /api/map/shifts","text":"<p>Create new shift.</p> <p>Request Body:</p> <pre><code>{\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</code></pre> <p>Response (201 Created):</p> <p>Returns created shift object (same format as GET).</p> <p>Validation:</p> <ul> <li><code>date</code> must be <code>YYYY-MM-DD</code> format</li> <li><code>startTime</code>, <code>endTime</code> must be <code>HH:MM</code> format</li> <li><code>maxVolunteers</code> must be >= 1</li> <li><code>cutId</code> is optional (for non-canvassing shifts)</li> </ul>"},{"location":"v2/backend/modules/shifts/#put-apimapshiftsid","title":"PUT /api/map/shifts/:id","text":"<p>Update shift. Auto-updates status if capacity changes.</p> <p>Request Body (Partial):</p> <pre><code>{\n \"maxVolunteers\": 20,\n \"status\": \"OPEN\"\n}\n</code></pre> <p>Response (200 OK):</p> <p>Returns updated shift object.</p> <p>Auto-Status Logic:</p> <pre><code>// When maxVolunteers is updated:\nif (currentVolunteers >= newMaxVolunteers && status === OPEN) {\n status = FULL;\n} else if (currentVolunteers < newMaxVolunteers && status === FULL) {\n status = OPEN;\n}\n</code></pre>"},{"location":"v2/backend/modules/shifts/#delete-apimapshiftsid","title":"DELETE /api/map/shifts/:id","text":"<p>Delete shift. Cascade deletes all signups.</p> <p>Response (204 No Content):</p> <p>No response body.</p>"},{"location":"v2/backend/modules/shifts/#post-apimapshiftsidsignups","title":"POST /api/map/shifts/:id/signups","text":"<p>Admin add volunteer signup.</p> <p>Request Body:</p> <pre><code>{\n \"userEmail\": \"volunteer@example.com\",\n \"userName\": \"Jane Volunteer\"\n}\n</code></pre> <p>Response (201 Created):</p> <pre><code>{\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</code></pre> <p>Behavior:</p> <ul> <li>Looks up user by email (if exists, links via <code>userId</code>)</li> <li>If signup was previously cancelled, re-activates it</li> <li>Increments <code>currentVolunteers</code></li> <li>Auto-updates shift status to <code>FULL</code> if capacity reached</li> <li>Transaction ensures atomicity</li> </ul> <p>Error Responses:</p> <ul> <li><code>400 Bad Request</code>: Shift is full</li> <li><code>404 Not Found</code>: Shift not found</li> <li><code>409 Conflict</code>: Volunteer already signed up</li> </ul>"},{"location":"v2/backend/modules/shifts/#delete-apimapshiftsidsignupssignupid","title":"DELETE /api/map/shifts/:id/signups/:signupId","text":"<p>Admin remove volunteer signup.</p> <p>Path Parameters:</p> <ul> <li><code>id</code> (string): Shift ID</li> <li><code>signupId</code> (string): Signup ID</li> </ul> <p>Response (204 No Content):</p> <p>No response body.</p> <p>Behavior:</p> <ul> <li>Updates signup status to <code>CANCELLED</code> (does not delete record)</li> <li>Decrements <code>currentVolunteers</code></li> <li>Auto-updates shift status to <code>OPEN</code></li> <li>Transaction ensures atomicity</li> </ul> <p>Error Responses:</p> <ul> <li><code>400 Bad Request</code>: Signup already cancelled</li> <li><code>404 Not Found</code>: Signup not found</li> </ul>"},{"location":"v2/backend/modules/shifts/#post-apimapshiftsidemail-details","title":"POST /api/map/shifts/:id/email-details","text":"<p>Email shift details to all confirmed volunteers.</p> <p>Example Request:</p> <pre><code>curl -X POST \\\n -H \"Authorization: Bearer <token>\" \\\n \"http://api.cmlite.org/api/map/shifts/clx1234567890/email-details\"\n</code></pre> <p>Response (200 OK):</p> <pre><code>{\n \"sent\": 8,\n \"failed\": 0\n}\n</code></pre> <p>Email Template:</p> <p>Uses <code>shift-details.html</code> and <code>shift-details.txt</code> templates with variables:</p> <ul> <li><code>USER_NAME</code> \u2014 Volunteer name</li> <li><code>SHIFT_TITLE</code> \u2014 Shift title</li> <li><code>SHIFT_DATE</code> \u2014 Formatted date</li> <li><code>SHIFT_START_TIME</code> \u2014 Start time</li> <li><code>SHIFT_END_TIME</code> \u2014 End time</li> <li><code>SHIFT_LOCATION</code> \u2014 Location</li> <li><code>SHIFT_DESCRIPTION</code> \u2014 Description</li> <li><code>CURRENT_VOLUNTEERS</code> \u2014 Current signup count</li> <li><code>MAX_VOLUNTEERS</code> \u2014 Max capacity</li> <li><code>SHIFT_STATUS</code> \u2014 Status</li> <li><code>ORGANIZATION_NAME</code> \u2014 Site settings org name</li> </ul>"},{"location":"v2/backend/modules/shifts/#volunteer-endpoint-details","title":"Volunteer Endpoint Details","text":""},{"location":"v2/backend/modules/shifts/#get-apimapshiftsvolunteerupcoming","title":"GET /api/map/shifts/volunteer/upcoming","text":"<p>Get upcoming public shifts with signup status for current user.</p> <p>Example Request:</p> <pre><code>curl -H \"Authorization: Bearer <token>\" \\\n \"http://api.cmlite.org/api/map/shifts/volunteer/upcoming\"\n</code></pre> <p>Response (200 OK):</p> <pre><code>[\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</code></pre> <p>Filtering:</p> <ul> <li>Only public shifts (<code>isPublic: true</code>)</li> <li>Only non-cancelled shifts</li> <li>Only shifts with date >= today</li> <li>Sorted by date ASC, then startTime ASC</li> </ul>"},{"location":"v2/backend/modules/shifts/#get-apimapshiftsvolunteermy-signups","title":"GET /api/map/shifts/volunteer/my-signups","text":"<p>Get current user's confirmed signups for upcoming shifts.</p> <p>Example Request:</p> <pre><code>curl -H \"Authorization: Bearer <token>\" \\\n \"http://api.cmlite.org/api/map/shifts/volunteer/my-signups\"\n</code></pre> <p>Response (200 OK):</p> <pre><code>[\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</code></pre> <p>Filtering:</p> <ul> <li>Signups by current user's email</li> <li>Only confirmed signups</li> <li>Only shifts with date >= today</li> <li>Only non-cancelled shifts</li> <li>Sorted by shift date ASC</li> </ul>"},{"location":"v2/backend/modules/shifts/#post-apimapshiftsvolunteeridsignup","title":"POST /api/map/shifts/volunteer/:id/signup","text":"<p>Authenticated user signs up for shift.</p> <p>Path Parameters:</p> <ul> <li><code>id</code> (string): Shift ID</li> </ul> <p>Rate Limiting:</p> <p>5 requests/min per IP (<code>shiftSignupRateLimit</code> middleware)</p> <p>Example Request:</p> <pre><code>curl -X POST \\\n -H \"Authorization: Bearer <token>\" \\\n \"http://api.cmlite.org/api/map/shifts/volunteer/clx1234567890/signup\"\n</code></pre> <p>Response (201 Created):</p> <pre><code>{\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</code></pre> <p>Validation:</p> <ul> <li>Shift must be public (<code>isPublic: true</code>)</li> <li>Shift must not be cancelled</li> <li>Shift must not have passed (date >= today)</li> <li>Shift must not be full</li> <li>User must not already be signed up</li> </ul> <p>Behavior:</p> <ul> <li>If previously cancelled signup exists, re-activates it</li> <li>Sends confirmation email (no temp password)</li> <li>Increments <code>currentVolunteers</code></li> <li>Auto-updates shift status to <code>FULL</code> if capacity reached</li> <li>Records Prometheus metric <code>cm_shift_signups_total</code></li> </ul> <p>Error Responses:</p> <ul> <li><code>400 Bad Request</code>: Shift is full, cancelled, or past</li> <li><code>403 Forbidden</code>: Shift is not public</li> <li><code>404 Not Found</code>: Shift not found</li> <li><code>409 Conflict</code>: Already signed up</li> <li><code>429 Too Many Requests</code>: Rate limit exceeded</li> </ul>"},{"location":"v2/backend/modules/shifts/#delete-apimapshiftsvolunteeridsignup","title":"DELETE /api/map/shifts/volunteer/:id/signup","text":"<p>Cancel own signup.</p> <p>Path Parameters:</p> <ul> <li><code>id</code> (string): Shift ID</li> </ul> <p>Example Request:</p> <pre><code>curl -X DELETE \\\n -H \"Authorization: Bearer <token>\" \\\n \"http://api.cmlite.org/api/map/shifts/volunteer/clx1234567890/signup\"\n</code></pre> <p>Response (204 No Content):</p> <p>No response body.</p> <p>Behavior:</p> <ul> <li>Updates signup status to <code>CANCELLED</code></li> <li>Decrements <code>currentVolunteers</code></li> <li>Auto-updates shift status to <code>OPEN</code></li> </ul> <p>Error Responses:</p> <ul> <li><code>400 Bad Request</code>: Already cancelled</li> <li><code>404 Not Found</code>: Signup not found</li> </ul>"},{"location":"v2/backend/modules/shifts/#public-endpoint-details","title":"Public Endpoint Details","text":""},{"location":"v2/backend/modules/shifts/#get-apimapshiftspublic","title":"GET /api/map/shifts/public","text":"<p>List public upcoming shifts (no auth required).</p> <p>Example Request:</p> <pre><code>curl http://api.cmlite.org/api/map/shifts/public\n</code></pre> <p>Response (200 OK):</p> <pre><code>[\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</code></pre> <p>Filtering:</p> <ul> <li>Only public shifts (<code>isPublic: true</code>)</li> <li>Only non-cancelled shifts</li> <li>Only shifts with date >= today</li> <li>Sorted by date ASC, then startTime ASC</li> </ul>"},{"location":"v2/backend/modules/shifts/#post-apimapshiftspublicidsignup","title":"POST /api/map/shifts/public/:id/signup","text":"<p>Public signup with temporary user creation.</p> <p>Path Parameters:</p> <ul> <li><code>id</code> (string): Shift ID</li> </ul> <p>Rate Limiting:</p> <p>5 requests/min per IP (<code>shiftSignupRateLimit</code> middleware)</p> <p>Request Body:</p> <pre><code>{\n \"email\": \"newvolunteer@example.com\",\n \"name\": \"John Doe\",\n \"phone\": \"+1234567890\"\n}\n</code></pre> <p>Response (201 Created):</p> <pre><code>{\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</code></pre> <p>Validation:</p> <ul> <li>Shift must be public</li> <li>Shift must be OPEN status</li> <li>Shift date must not have passed</li> <li>Shift must not be full</li> <li>Email must not already be signed up</li> </ul> <p>Behavior \u2014 New User:</p> <p>If email does not exist in database:</p> <ol> <li> <p>Generate readable password: <pre><code>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</code></pre></p> </li> <li> <p>Create TEMP user: <pre><code>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</code></pre></p> </li> <li> <p>Send confirmation email with temp password: <pre><code>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</code></pre></p> </li> </ol> <p>Behavior \u2014 Existing User:</p> <p>If email exists in database:</p> <ul> <li>Links signup to existing user via <code>userId</code></li> <li>No password generated or sent</li> <li>Sets <code>signupSource</code> to <code>AUTHENTICATED</code></li> </ul> <p>Behavior \u2014 Re-activation:</p> <p>If cancelled signup exists:</p> <ul> <li>Re-activates existing signup record (status \u2192 CONFIRMED)</li> <li>Does not create duplicate signup</li> </ul> <p>Transaction:</p> <ul> <li>Signup creation + <code>currentVolunteers</code> increment + status update are atomic</li> </ul> <p>Error Responses:</p> <ul> <li><code>400 Bad Request</code>: Shift full, not open, or past</li> <li><code>403 Forbidden</code>: Shift not public</li> <li><code>404 Not Found</code>: Shift not found</li> <li><code>409 Conflict</code>: Already signed up</li> <li><code>429 Too Many Requests</code>: Rate limit exceeded</li> </ul>"},{"location":"v2/backend/modules/shifts/#service-functions","title":"Service Functions","text":""},{"location":"v2/backend/modules/shifts/#shiftsservicefindallfilters","title":"shiftsService.findAll(filters)","text":"<p>List shifts with pagination, search, and filtering.</p> <p>Usage:</p> <pre><code>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</code></pre> <p>Search Behavior:</p> <pre><code>if (search) {\n where.OR = [\n { title: { contains: search, mode: 'insensitive' } },\n { location: { contains: search, mode: 'insensitive' } },\n ];\n}\n</code></pre>"},{"location":"v2/backend/modules/shifts/#shiftsservicefindbyidid","title":"shiftsService.findById(id)","text":"<p>Get single shift with signups list.</p> <p>Usage:</p> <pre><code>const shift = await shiftsService.findById('clx1234567890');\nconsole.log(shift.signups.length); // Confirmed signups only\nconsole.log(shift.cut?.name); // Cut name if associated\n</code></pre> <p>Throws:</p> <ul> <li><code>AppError(404)</code> if shift not found</li> </ul>"},{"location":"v2/backend/modules/shifts/#shiftsservicecreatedata-userid","title":"shiftsService.create(data, userId)","text":"<p>Create shift.</p> <p>Usage:</p> <pre><code>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</code></pre>"},{"location":"v2/backend/modules/shifts/#shiftsserviceupdateid-data","title":"shiftsService.update(id, data)","text":"<p>Update shift with auto-status management.</p> <p>Usage:</p> <pre><code>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</code></pre> <p>Auto-Status Logic:</p> <pre><code>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</code></pre>"},{"location":"v2/backend/modules/shifts/#shiftsserviceaddsignupshiftid-data","title":"shiftsService.addSignup(shiftId, data)","text":"<p>Admin add volunteer signup.</p> <p>Usage:</p> <pre><code>const signup = await shiftsService.addSignup('clx1234567890', {\n userEmail: 'volunteer@example.com',\n userName: 'Jane Volunteer',\n});\n\nconsole.log(signup.signupSource); // 'ADMIN'\n</code></pre> <p>Behavior:</p> <ul> <li>Looks up user by email</li> <li>Re-activates cancelled signup if exists</li> <li>Atomic transaction (signup + capacity + status)</li> </ul> <p>Throws:</p> <ul> <li><code>AppError(400)</code> if shift full</li> <li><code>AppError(404)</code> if shift not found</li> <li><code>AppError(409)</code> if already signed up</li> </ul>"},{"location":"v2/backend/modules/shifts/#shiftsservicepublicsignupshiftid-data","title":"shiftsService.publicSignup(shiftId, data)","text":"<p>Public signup with temp user creation.</p> <p>Usage:</p> <pre><code>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</code></pre> <p>Temp User Expiry:</p> <pre><code>const shiftDate = new Date(shift.date);\nshiftDate.setDate(shiftDate.getDate() + 1); // Expires day after shift\n</code></pre> <p>Email Template Variables:</p> <pre><code>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</code></pre> <p>Metrics:</p> <p>Records <code>cm_shift_signups_total</code> Prometheus counter.</p> <p>Throws:</p> <ul> <li><code>AppError(400)</code> if shift full, not open, or past</li> <li><code>AppError(403)</code> if not public</li> <li><code>AppError(404)</code> if shift not found</li> <li><code>AppError(409)</code> if duplicate signup</li> </ul>"},{"location":"v2/backend/modules/shifts/#shiftsserviceremovesignupsignupid","title":"shiftsService.removeSignup(signupId)","text":"<p>Cancel signup (admin).</p> <p>Usage:</p> <pre><code>await shiftsService.removeSignup('clx2222222222');\n\n// Signup status \u2192 CANCELLED\n// currentVolunteers decremented\n// Shift status \u2192 OPEN\n</code></pre> <p>Atomic Transaction:</p> <pre><code>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</code></pre>"},{"location":"v2/backend/modules/shifts/#shiftsserviceemailshiftdetailsshiftid","title":"shiftsService.emailShiftDetails(shiftId)","text":"<p>Email shift details to all confirmed volunteers.</p> <p>Usage:</p> <pre><code>const result = await shiftsService.emailShiftDetails('clx1234567890');\nconsole.log(`Sent: ${result.sent}, Failed: ${result.failed}`);\n</code></pre> <p>Email Template:</p> <p>Uses <code>shift-details.html</code> and <code>shift-details.txt</code> with variables:</p> <ul> <li><code>USER_NAME</code></li> <li><code>SHIFT_TITLE</code></li> <li><code>SHIFT_DATE</code></li> <li><code>SHIFT_START_TIME</code></li> <li><code>SHIFT_END_TIME</code></li> <li><code>SHIFT_LOCATION</code></li> <li><code>SHIFT_DESCRIPTION</code></li> <li><code>CURRENT_VOLUNTEERS</code></li> <li><code>MAX_VOLUNTEERS</code></li> <li><code>SHIFT_STATUS</code></li> <li><code>ORGANIZATION_NAME</code></li> </ul> <p>Error Handling:</p> <p>Individual email failures are logged but do not stop batch processing.</p>"},{"location":"v2/backend/modules/shifts/#validation-schemas","title":"Validation Schemas","text":""},{"location":"v2/backend/modules/shifts/#create-shift-schema","title":"Create Shift Schema","text":"<pre><code>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</code></pre> <p>Example Valid Input:</p> <pre><code>{\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</code></pre>"},{"location":"v2/backend/modules/shifts/#update-shift-schema","title":"Update Shift Schema","text":"<pre><code>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</code></pre> <p>Partial Updates:</p> <p>All fields optional. Only provided fields are updated.</p>"},{"location":"v2/backend/modules/shifts/#public-signup-schema","title":"Public Signup Schema","text":"<pre><code>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</code></pre> <p>Example Valid Input:</p> <pre><code>{\n \"email\": \"volunteer@example.com\",\n \"name\": \"Jane Volunteer\",\n \"phone\": \"+1234567890\"\n}\n</code></pre>"},{"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":"<pre><code>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</code></pre>"},{"location":"v2/backend/modules/shifts/#volunteer-sign-up-for-shift","title":"Volunteer: Sign Up for Shift","text":"<pre><code>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</code></pre>"},{"location":"v2/backend/modules/shifts/#public-sign-up-without-account","title":"Public: Sign Up Without Account","text":"<pre><code>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</code></pre>"},{"location":"v2/backend/modules/shifts/#admin-email-all-volunteers","title":"Admin: Email All Volunteers","text":"<pre><code>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</code></pre>"},{"location":"v2/backend/modules/shifts/#frontend-integration","title":"Frontend Integration","text":"<p>The ShiftsPage component (<code>admin/src/pages/ShiftsPage.tsx</code>) provides:</p> <ul> <li>Paginated shifts table with search and status filter</li> <li>Cut association dropdown (optional, for canvassing shifts)</li> <li>Capacity badges (8/15 with OPEN/FULL status)</li> <li>Create shift modal with date/time pickers</li> <li>Edit shift modal (pre-populated form)</li> <li>Delete confirmation modal</li> <li>Signups drawer (shows volunteers, email all button, remove signup)</li> <li>Public/private toggle (controls <code>isPublic</code> flag)</li> <li>Status badges (OPEN=green, FULL=orange, CANCELLED=red)</li> </ul> <p>State Management:</p> <pre><code>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</code></pre> <p>Volunteer Portal:</p> <p>The VolunteerShiftsPage component (<code>admin/src/pages/volunteer/VolunteerShiftsPage.tsx</code>) provides:</p> <ul> <li>Upcoming shifts cards with shift details</li> <li>Signup status badges (\"Signed Up\" vs \"Join Now\")</li> <li>Capacity indicators (8/15 volunteers)</li> <li>Signup confirmation modal</li> <li>Cancel signup functionality</li> <li>My signups tab (shows user's confirmed signups)</li> </ul> <p>Public Page:</p> <p>The ShiftsPage component (<code>admin/src/pages/public/ShiftsPage.tsx</code>) provides:</p> <ul> <li>Public shift cards with date/time/location</li> <li>Signup modal (collects email, name, phone)</li> <li>Capacity indicators</li> <li>Status badges</li> <li>Responsive grid layout</li> </ul>"},{"location":"v2/backend/modules/shifts/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/backend/modules/shifts/#capacity-tracking","title":"Capacity Tracking","text":"<p>The <code>currentVolunteers</code> field is denormalized for performance:</p> <pre><code>// 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</code></pre> <p>Pros:</p> <ul> <li>No joins or aggregations needed for shift listings</li> <li>Instant capacity checks</li> <li>Fast status filtering</li> </ul> <p>Cons:</p> <ul> <li>Must maintain consistency in transactions</li> <li>Risk of drift if transactions fail</li> </ul> <p>Consistency Checks:</p> <p>Run periodic reconciliation:</p> <pre><code>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</code></pre>"},{"location":"v2/backend/modules/shifts/#unique-constraint-performance","title":"Unique Constraint Performance","text":"<p>The <code>[shiftId, userEmail]</code> unique constraint enables fast duplicate checks:</p> <pre><code>const existing = await prisma.shiftSignup.findUnique({\n where: { shiftId_userEmail: { shiftId, userEmail: data.email } },\n});\n</code></pre> <p>Index Usage:</p> <ul> <li>PostgreSQL uses the unique index for lookups</li> <li>O(log n) lookup time</li> <li>No full table scan</li> </ul>"},{"location":"v2/backend/modules/shifts/#rate-limiting","title":"Rate Limiting","text":"<p>The <code>shiftSignupRateLimit</code> middleware protects against signup spam:</p> <pre><code>// 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</code></pre> <p>Why 5/min?</p> <ul> <li>Allows legitimate users to sign up for multiple shifts quickly</li> <li>Prevents automated abuse</li> <li>Balances UX with security</li> </ul>"},{"location":"v2/backend/modules/shifts/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/backend/modules/shifts/#shift-status-not-updating-automatically","title":"Shift Status Not Updating Automatically","text":"<p>Problem:</p> <p>Shift status stays FULL even after volunteer cancels.</p> <p>Diagnosis:</p> <p>Check transaction logic in <code>removeSignup</code>:</p> <pre><code>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</code></pre> <p>Solution:</p> <p>Status is always set to OPEN on cancel. If shift should remain FULL (e.g., still at capacity), check if another transaction occurred simultaneously.</p>"},{"location":"v2/backend/modules/shifts/#duplicate-signups","title":"Duplicate Signups","text":"<p>Problem:</p> <p>User signed up twice for same shift.</p> <p>Diagnosis:</p> <p>Check unique constraint enforcement:</p> <pre><code>SELECT * FROM shift_signups\nWHERE \"shiftId\" = 'clx1234567890' AND \"userEmail\" = 'volunteer@example.com';\n</code></pre> <p>Possible Causes:</p> <ul> <li>Constraint disabled</li> <li>Race condition (two requests hit database before first commit)</li> <li>Manual database insertion bypassing constraint</li> </ul> <p>Solution:</p> <ul> <li>Verify constraint exists: <code>\\d shift_signups</code> in psql</li> <li>Add application-level locking if race conditions persist: <pre><code>await prisma.$transaction([\n prisma.shiftSignup.findUnique({ /* check */ }),\n prisma.shiftSignup.create({ /* create */ }),\n], { isolationLevel: 'Serializable' });\n</code></pre></li> </ul>"},{"location":"v2/backend/modules/shifts/#confirmation-emails-not-sending","title":"Confirmation Emails Not Sending","text":"<p>Problem:</p> <p>Volunteers sign up but don't receive confirmation emails.</p> <p>Diagnosis:</p> <p>Check email service logs:</p> <pre><code>docker compose logs -f api | grep \"shift signup confirmation\"\n</code></pre> <p>Common Causes:</p> <ol> <li> <p>MailHog mode enabled: <pre><code>EMAIL_TEST_MODE=true # Emails go to MailHog, not SMTP\n</code></pre></p> </li> <li> <p>SMTP misconfiguration: <pre><code>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</code></pre></p> </li> <li> <p>Template missing: <pre><code># Check template exists\nls api/src/templates/shift-signup-confirmation.html\nls api/src/templates/shift-signup-confirmation.txt\n</code></pre></p> </li> <li> <p>Email service crash: <pre><code>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</code></pre></p> </li> </ol> <p>Solution:</p> <ul> <li>Set <code>EMAIL_TEST_MODE=false</code> for production</li> <li>Verify SMTP credentials</li> <li>Ensure templates exist</li> <li>Check email logs for detailed errors</li> </ul>"},{"location":"v2/backend/modules/shifts/#temp-users-not-expiring","title":"Temp Users Not Expiring","text":"<p>Problem:</p> <p>TEMP users created via public signup still active long after shift.</p> <p>Diagnosis:</p> <p>Check <code>expiresAt</code> value:</p> <pre><code>SELECT id, email, role, \"expiresAt\", \"createdAt\"\nFROM users\nWHERE role = 'TEMP' AND \"expiresAt\" < NOW() AND status = 'ACTIVE';\n</code></pre> <p>Expected Behavior:</p> <ul> <li><code>expiresAt</code> is set to shift date + 1 day</li> <li>Expired users should be marked EXPIRED by auth middleware</li> </ul> <p>Solution:</p> <p>Run cleanup script or add cron job:</p> <pre><code>// 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</code></pre>"},{"location":"v2/backend/modules/shifts/#rate-limit-too-strict","title":"Rate Limit Too Strict","text":"<p>Problem:</p> <p>Users get rate-limited when legitimately signing up for multiple shifts.</p> <p>Diagnosis:</p> <p>Check rate limit config:</p> <pre><code>export const shiftSignupRateLimit = createRateLimiter({\n windowMs: 60 * 1000, // 1 minute\n max: 5, // 5 signups per minute\n});\n</code></pre> <p>Solution:</p> <p>Increase limit if legitimate use case:</p> <pre><code>max: 10, // Allow 10 signups per minute\n</code></pre> <p>Alternative:</p> <p>Whitelist admin IPs:</p> <pre><code>skip: (req) => {\n const ip = req.ip || req.connection.remoteAddress;\n return ['127.0.0.1', '::1', ADMIN_IP].includes(ip);\n},\n</code></pre>"},{"location":"v2/backend/modules/shifts/#related-documentation","title":"Related Documentation","text":"<ul> <li>Canvass Module - Volunteer canvassing system (uses <code>Shift.cutId</code>)</li> <li>Cuts Module - Polygon management (cut association)</li> <li>Users Module - User CRUD (temp user management)</li> <li>Auth Module - JWT authentication (temp user login)</li> <li>Settings Module - Organization name for emails</li> <li>Email Service - Email sending (confirmation emails)</li> <li>Frontend: ShiftsPage - Admin shift management UI</li> <li>Frontend: Volunteer Portal - Volunteer shift signup UI</li> <li>Frontend: Public Shifts Page - Public shift listings</li> <li>API Reference: Shifts - Complete endpoint reference</li> <li>User Guide: Volunteer Manager - Managing volunteers</li> <li>Troubleshooting: Email Issues - Email debugging guide</li> </ul>"},{"location":"v2/backend/modules/users/","title":"Users Module","text":""},{"location":"v2/backend/modules/users/#overview","title":"Overview","text":"<p>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.</p> <p>Key Features:</p> <ul> <li>Full CRUD operations with role-based permissions</li> <li>Paginated list with search (email, name) and filters (role, status)</li> <li>Self-service profile updates for regular users</li> <li>Admin-only role and status changes</li> <li>Password hashing with bcrypt (12 salt rounds)</li> <li>Temporary user expiration handling</li> <li>Email uniqueness validation</li> <li>Cascading delete for related records</li> </ul>"},{"location":"v2/backend/modules/users/#file-paths","title":"File Paths","text":"File Purpose <code>api/src/modules/users/users.routes.ts</code> Express router with 5 CRUD endpoints <code>api/src/modules/users/users.service.ts</code> User management business logic <code>api/src/modules/users/users.schemas.ts</code> Zod validation schemas"},{"location":"v2/backend/modules/users/#database-model","title":"Database Model","text":"<p>The Users module uses the <code>User</code> model from the Auth module:</p> <pre><code>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</code></pre>"},{"location":"v2/backend/modules/users/#api-endpoints","title":"API Endpoints","text":"Method Path Auth Permissions Description GET <code>/api/users</code> Required Admin roles List users with pagination/filters GET <code>/api/users/:id</code> Required Admin or self Get user by ID POST <code>/api/users</code> Required Admin roles Create new user PUT <code>/api/users/:id</code> Required Admin or self Update user DELETE <code>/api/users/:id</code> Required Admin roles Delete user <p>Admin Roles: <code>SUPER_ADMIN</code>, <code>INFLUENCE_ADMIN</code>, <code>MAP_ADMIN</code></p>"},{"location":"v2/backend/modules/users/#endpoint-details","title":"Endpoint Details","text":""},{"location":"v2/backend/modules/users/#get-apiusers","title":"GET /api/users","text":"<p>List users with pagination, search, and filtering (admin only).</p> <p>Request Headers:</p> <pre><code>Authorization: Bearer <access_token>\n</code></pre> <p>Query Parameters:</p> Parameter Type Required Default Description page number No 1 Page number (1-indexed) limit number No 20 Results per page (max 100) search string No - Search email or name (case-insensitive) role UserRole No - Filter by role status UserStatus No - Filter by status <p>Example Request:</p> <pre><code>curl -H \"Authorization: Bearer <token>\" \\\n \"http://api.cmlite.org/api/users?page=1&limit=20&search=john&role=USER&status=ACTIVE\"\n</code></pre> <p>Response (200 OK):</p> <pre><code>{\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</code></pre> <p>Implementation:</p> <pre><code>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</code></pre> <p>Search Logic:</p> <pre><code>if (search) {\n where.OR = [\n { email: { contains: search, mode: 'insensitive' } },\n { name: { contains: search, mode: 'insensitive' } },\n ];\n}\n</code></pre>"},{"location":"v2/backend/modules/users/#get-apiusersid","title":"GET /api/users/:id","text":"<p>Get user by ID. Admins can view any user, regular users can only view themselves.</p> <p>Request Headers:</p> <pre><code>Authorization: Bearer <access_token>\n</code></pre> <p>Path Parameters:</p> <ul> <li><code>id</code> (string): User ID</li> </ul> <p>Example Request:</p> <pre><code>curl -H \"Authorization: Bearer <token>\" \\\n \"http://api.cmlite.org/api/users/clx1234567890\"\n</code></pre> <p>Response (200 OK):</p> <pre><code>{\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</code></pre> <p>Error Responses:</p> <ul> <li><code>403 Forbidden</code>: Non-admin trying to view another user</li> <li><code>404 Not Found</code>: User ID does not exist</li> </ul> <p>Permission Logic:</p> <pre><code>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</code></pre>"},{"location":"v2/backend/modules/users/#post-apiusers","title":"POST /api/users","text":"<p>Create new user account (admin only). Unlike public registration, admins can set any role.</p> <p>Request Headers:</p> <pre><code>Authorization: Bearer <access_token>\nContent-Type: application/json\n</code></pre> <p>Request Body:</p> <pre><code>{\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</code></pre> <p>Field Details:</p> Field Type Required Description email string Yes Unique email address password string Yes Minimum 8 characters (admin creation has relaxed policy) name string No Full name phone string No Phone number role UserRole No Default: USER status UserStatus No Default: ACTIVE expiresAt ISO 8601 No Expiration timestamp (for TEMP users) expireDays number No Days until expiration <p>Response (201 Created):</p> <pre><code>{\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</code></pre> <p>Error Responses:</p> <ul> <li><code>409 Conflict</code>: Email already registered</li> <li><code>403 Forbidden</code>: Non-admin trying to create user</li> </ul>"},{"location":"v2/backend/modules/users/#put-apiusersid","title":"PUT /api/users/:id","text":"<p>Update user. Admins can update any user and change role/status. Regular users can update their own profile (except role/status).</p> <p>Request Headers:</p> <pre><code>Authorization: Bearer <access_token>\nContent-Type: application/json\n</code></pre> <p>Path Parameters:</p> <ul> <li><code>id</code> (string): User ID to update</li> </ul> <p>Request Body (Partial Update):</p> <pre><code>{\n \"name\": \"Updated Name\",\n \"phone\": \"+0987654321\",\n \"email\": \"newemail@example.com\",\n \"password\": \"NewPass123\",\n \"role\": \"INFLUENCE_ADMIN\",\n \"status\": \"SUSPENDED\"\n}\n</code></pre> <p>All fields are optional (partial updates supported).</p> <p>Response (200 OK):</p> <pre><code>{\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</code></pre> <p>Error Responses:</p> <ul> <li><code>403 Forbidden</code>: Non-admin trying to update another user or change role/status</li> <li><code>404 Not Found</code>: User ID does not exist</li> <li><code>409 Conflict</code>: Email already in use by another user</li> </ul> <p>Permission Logic:</p> <pre><code>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</code></pre> <p>Password Handling:</p> <pre><code>if (data.password) {\n updateData.password = await bcrypt.hash(data.password, 12);\n}\n</code></pre>"},{"location":"v2/backend/modules/users/#delete-apiusersid","title":"DELETE /api/users/:id","text":"<p>Delete user (admin only). Cascades to related records (refresh tokens, created campaigns, etc.).</p> <p>Request Headers:</p> <pre><code>Authorization: Bearer <access_token>\n</code></pre> <p>Path Parameters:</p> <ul> <li><code>id</code> (string): User ID to delete</li> </ul> <p>Example Request:</p> <pre><code>curl -X DELETE \\\n -H \"Authorization: Bearer <token>\" \\\n \"http://api.cmlite.org/api/users/clx1234567890\"\n</code></pre> <p>Response (204 No Content):</p> <p>No response body.</p> <p>Error Responses:</p> <ul> <li><code>403 Forbidden</code>: Non-admin trying to delete user</li> <li><code>404 Not Found</code>: User ID does not exist</li> </ul> <p>Cascading Deletes:</p> <p>Deleting a user automatically deletes: - Refresh tokens - Created campaigns (if <code>createdByUserId</code> relation) - Created locations (if <code>createdByUserId</code> relation) - Campaign emails - Responses - Shift signups</p>"},{"location":"v2/backend/modules/users/#service-functions","title":"Service Functions","text":""},{"location":"v2/backend/modules/users/#usersservicefindallfilters","title":"usersService.findAll(filters)","text":"<p>Purpose: Paginated user listing with search and filters.</p> <p>Parameters:</p> <pre><code>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</code></pre> <p>Returns:</p> <pre><code>{\n users: User[];\n pagination: {\n page: number;\n limit: number;\n total: number;\n totalPages: number;\n };\n}\n</code></pre> <p>Implementation:</p> <pre><code>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</code></pre>"},{"location":"v2/backend/modules/users/#usersservicefindbyidid","title":"usersService.findById(id)","text":"<p>Purpose: Get single user by ID.</p> <p>Returns: User object or throws <code>404</code> error.</p> <p>Security: Password excluded via <code>select</code> (never returned in API responses).</p>"},{"location":"v2/backend/modules/users/#usersservicecreatedata","title":"usersService.create(data)","text":"<p>Purpose: Create new user with hashed password.</p> <p>Flow:</p> <ol> <li>Check if email already exists (<code>409</code> if duplicate)</li> <li>Hash password with bcrypt (12 salt rounds)</li> <li>Create user in database</li> <li>Return user (password excluded)</li> </ol> <p>Expiration Handling:</p> <pre><code>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</code></pre>"},{"location":"v2/backend/modules/users/#usersserviceupdateid-data","title":"usersService.update(id, data)","text":"<p>Purpose: Update existing user (partial updates supported).</p> <p>Validation:</p> <ul> <li>Check user exists (<code>404</code> if not found)</li> <li>Check email uniqueness if changing email (<code>409</code> if taken)</li> <li>Hash password if provided</li> <li>Convert <code>expiresAt</code> string to Date</li> </ul> <p>Email Change:</p> <pre><code>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</code></pre>"},{"location":"v2/backend/modules/users/#usersservicedeleteid","title":"usersService.delete(id)","text":"<p>Purpose: Delete user and cascade to related records.</p> <p>Error Handling:</p> <pre><code>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</code></pre>"},{"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":"<pre><code>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</code></pre>"},{"location":"v2/backend/modules/users/#admin-create-user","title":"Admin: Create User","text":"<pre><code>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</code></pre>"},{"location":"v2/backend/modules/users/#admin-update-user-role","title":"Admin: Update User Role","text":"<pre><code>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</code></pre>"},{"location":"v2/backend/modules/users/#user-update-own-profile","title":"User: Update Own Profile","text":"<pre><code>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</code></pre>"},{"location":"v2/backend/modules/users/#admin-suspend-user","title":"Admin: Suspend User","text":"<pre><code>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</code></pre>"},{"location":"v2/backend/modules/users/#admin-delete-user","title":"Admin: Delete User","text":"<pre><code>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</code></pre>"},{"location":"v2/backend/modules/users/#frontend-integration","title":"Frontend Integration","text":"<p>The UsersPage component (<code>admin/src/pages/UsersPage.tsx</code>) provides a comprehensive UI for user management:</p> <p>Features:</p> <ul> <li>Paginated table with role/status badges</li> <li>Search by email or name (300ms debounce)</li> <li>Filter dropdowns (role, status)</li> <li>Create user modal with form validation</li> <li>Edit user modal (pre-populated form)</li> <li>Delete confirmation modal</li> <li>Responsive design (mobile-friendly)</li> </ul> <p>State Management:</p> <pre><code>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</code></pre> <p>API Integration:</p> <pre><code>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</code></pre>"},{"location":"v2/backend/modules/users/#validation-schemas","title":"Validation Schemas","text":""},{"location":"v2/backend/modules/users/#create-user-schema","title":"Create User Schema","text":"<pre><code>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</code></pre> <p>Note: Admin user creation has relaxed password requirements (8 chars vs. 12 for public registration).</p>"},{"location":"v2/backend/modules/users/#update-user-schema","title":"Update User Schema","text":"<pre><code>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</code></pre>"},{"location":"v2/backend/modules/users/#list-users-schema","title":"List Users Schema","text":"<pre><code>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</code></pre>"},{"location":"v2/backend/modules/users/#security-considerations","title":"Security Considerations","text":""},{"location":"v2/backend/modules/users/#password-security","title":"Password Security","text":"<ul> <li>Hashing: bcrypt with 12 salt rounds (admin creation) or enforced by registration schema</li> <li>Never Returned: Password excluded from all API responses via <code>select</code> clause</li> <li>Updates: Re-hashed when changed</li> </ul>"},{"location":"v2/backend/modules/users/#permission-model","title":"Permission Model","text":"Action SUPER_ADMIN INFLUENCE_ADMIN MAP_ADMIN USER List users \u2705 \u2705 \u2705 \u274c View any user \u2705 \u2705 \u2705 Own profile only Create user \u2705 \u2705 \u2705 \u274c Update any user \u2705 \u2705 \u2705 Own profile only Change role/status \u2705 \u2705 \u2705 \u274c Delete user \u2705 \u2705 \u2705 \u274c"},{"location":"v2/backend/modules/users/#email-uniqueness","title":"Email Uniqueness","text":"<ul> <li>Enforced at database level (<code>@unique</code> constraint)</li> <li>Checked before creation (<code>409 Conflict</code>)</li> <li>Checked before email change (<code>409 Conflict</code>)</li> </ul>"},{"location":"v2/backend/modules/users/#cascading-deletes","title":"Cascading Deletes","text":"<p>Deleting a user automatically deletes related records via Prisma <code>onDelete: Cascade</code>:</p> <ul> <li>RefreshTokens</li> <li>Created campaigns</li> <li>Created locations</li> <li>Campaign emails</li> <li>Responses</li> <li>Shift signups</li> </ul>"},{"location":"v2/backend/modules/users/#related-documentation","title":"Related Documentation","text":"<ul> <li>Auth Module - Authentication and JWT tokens</li> <li>Middleware: RBAC - Role-based access control</li> <li>Frontend: UsersPage - User management UI</li> <li>Database: User Model - User schema documentation</li> <li>API Reference: Users - Complete endpoint reference</li> <li>User Guide: Admin - Managing users guide</li> </ul>"},{"location":"v2/backend/services/","title":"Backend Services","text":"<p>Shared services provide cross-cutting functionality across the Changemaker Lite platform. These services handle external integrations, background processing, and common operations.</p>"},{"location":"v2/backend/services/#service-architecture","title":"Service Architecture","text":"<p>Services are singleton instances that provide:</p> <ul> <li>External API integrations (email, geocoding, newsletters)</li> <li>Background job processing (email queues, geocoding queues)</li> <li>Caching and data management</li> <li>Infrastructure operations (Docker, tunneling)</li> </ul>"},{"location":"v2/backend/services/#core-services","title":"Core Services","text":""},{"location":"v2/backend/services/#email-services","title":"Email Services","text":"<p>Email Service (<code>email.service.ts</code>)</p> <ul> <li>Nodemailer SMTP wrapper</li> <li>Template processing with variable substitution</li> <li>Test mode support (MailHog integration)</li> <li>HTML email generation</li> <li>Attachment handling</li> </ul> <p>Email Queue Service (<code>email-queue.service.ts</code>)</p> <ul> <li>BullMQ queue management</li> <li>Worker process for async email sending</li> <li>Job retry logic with exponential backoff</li> <li>Queue monitoring and statistics</li> <li>Batch email processing</li> </ul>"},{"location":"v2/backend/services/#geocoding-services","title":"Geocoding Services","text":"<p>Geocoding Service (<code>geocoding.service.ts</code>)</p> <ul> <li>Multi-provider geocoding (6 providers)</li> <li>Nominatim (OpenStreetMap)</li> <li>ArcGIS</li> <li>Photon</li> <li>Mapbox</li> <li>Google Geocoding API</li> <li>Pelias</li> <li>Provider fallback chain</li> <li>Rate limiting per provider</li> <li>Result caching</li> <li>Batch geocoding support</li> </ul> <p>Geocode Queue Service (<code>geocode-queue.service.ts</code>)</p> <ul> <li>BullMQ queue for async geocoding</li> <li>Worker process with provider rotation</li> <li>Progress tracking</li> <li>Error handling and retry logic</li> <li>Batch processing optimization</li> </ul>"},{"location":"v2/backend/services/#integration-services","title":"Integration Services","text":"<p>Listmonk Client (<code>listmonk.client.ts</code>)</p> <ul> <li>Typed HTTP client for Listmonk REST API</li> <li>Basic auth integration</li> <li>List management operations</li> <li>Subscriber CRUD</li> <li>Campaign operations</li> </ul> <p>Listmonk Sync Service (<code>listmonk-sync.service.ts</code>)</p> <ul> <li>Opt-in sync (controlled by <code>LISTMONK_SYNC_ENABLED</code>)</li> <li>Participant \u2192 subscriber sync</li> <li>Location \u2192 list management</li> <li>User role \u2192 list assignment</li> <li>Automated sync on campaign actions</li> </ul> <p>Pangolin Client (<code>pangolin.client.ts</code>)</p> <ul> <li>Typed HTTP client for Pangolin Integration API</li> <li>API key authentication</li> <li>Tunnel management</li> <li>Site configuration</li> <li>Resource operations</li> </ul>"},{"location":"v2/backend/services/#infrastructure-services","title":"Infrastructure Services","text":"<p>Docker Service (<code>docker.service.ts</code>)</p> <ul> <li>Container lifecycle management</li> <li>Health check monitoring</li> <li>Service status queries</li> <li>Container operations (start, stop, restart)</li> <li>Resource monitoring</li> </ul>"},{"location":"v2/backend/services/#service-list","title":"Service List","text":"Service Purpose Dependencies Email Service SMTP email delivery Nodemailer Email Queue Async email processing BullMQ, Redis Geocoding Address \u2192 coordinates Multiple providers Geocode Queue Async geocoding BullMQ, Redis Listmonk Client Newsletter API Native fetch Listmonk Sync Automated list sync Listmonk Client Pangolin Client Tunnel API Native fetch Docker Service Container ops Docker API"},{"location":"v2/backend/services/#configuration","title":"Configuration","text":"<p>Services are configured via environment variables in <code>api/src/config/env.ts</code>:</p> <pre><code>// 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</code></pre>"},{"location":"v2/backend/services/#usage-patterns","title":"Usage Patterns","text":""},{"location":"v2/backend/services/#email-service","title":"Email Service","text":"<pre><code>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</code></pre>"},{"location":"v2/backend/services/#email-queue","title":"Email Queue","text":"<pre><code>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</code></pre>"},{"location":"v2/backend/services/#geocoding-service","title":"Geocoding Service","text":"<pre><code>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</code></pre>"},{"location":"v2/backend/services/#related-documentation","title":"Related Documentation","text":"<ul> <li>Backend Overview</li> <li>Modules</li> <li>Environment Variables</li> <li>Email Queue Feature</li> <li>Geocoding Feature</li> <li>Newsletter Integration</li> </ul>"},{"location":"v2/backend/utilities/","title":"Backend Utilities","text":"<p>Utility modules provide common functionality for spatial calculations, logging, metrics collection, and data processing across the Changemaker Lite platform.</p>"},{"location":"v2/backend/utilities/#utility-modules","title":"Utility Modules","text":""},{"location":"v2/backend/utilities/#spatial-utilities","title":"Spatial Utilities","text":"<p>spatial.ts (<code>utils/spatial.ts</code>)</p> <p>Provides geospatial calculations and polygon operations:</p> <p>Point-in-Polygon - Ray-casting algorithm for polygon containment - Supports GeoJSON polygon format - Handles holes in polygons - Used for cut assignment</p> <pre><code>import { isPointInPolygon } from '../utils/spatial';\n\nconst inside = isPointInPolygon(\n { lat: 43.65, lng: -79.38 },\n geoJsonPolygon\n);\n</code></pre> <p>Haversine Distance - Calculate distance between two coordinates - Returns distance in kilometers - Great-circle distance calculation</p> <pre><code>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</code></pre> <p>Bounds Calculation - Calculate bounding box for set of locations - Returns min/max lat/lng - Used for map centering</p> <pre><code>import { calculateBounds } from '../utils/spatial';\n\nconst bounds = calculateBounds(locations);\n// Returns: { minLat, maxLat, minLng, maxLng }\n</code></pre> <p>Centroid Calculation - Calculate center point of locations - Geographic mean of coordinates - Used for map initial center</p> <pre><code>import { calculateCentroid } from '../utils/spatial';\n\nconst center = calculateCentroid(locations);\n// Returns: { lat, lng }\n</code></pre> <p>GeoJSON Parsing - Parse GeoJSON geometry to coordinate arrays - Support for Polygon and MultiPolygon - Coordinate validation</p>"},{"location":"v2/backend/utilities/#logging-utilities","title":"Logging Utilities","text":"<p>logger.ts (<code>utils/logger.ts</code>)</p> <p>Winston-based logging with multiple transports:</p> <p>Log Levels - <code>error</code> - Error conditions - <code>warn</code> - Warning messages - <code>info</code> - Informational messages - <code>http</code> - HTTP request logs - <code>debug</code> - Debug-level messages</p> <p>Usage</p> <pre><code>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</code></pre> <p>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</p>"},{"location":"v2/backend/utilities/#metrics-utilities","title":"Metrics Utilities","text":"<p>metrics.ts (<code>utils/metrics.ts</code>)</p> <p>Prometheus metrics collection with 12 custom <code>cm_*</code> metrics:</p> <p>Counter Metrics - <code>cm_api_uptime_seconds</code> - API uptime counter - <code>cm_canvass_visits_total</code> - Total canvass visits - <code>cm_campaign_emails_sent_total</code> - Total campaign emails - <code>cm_geocode_requests_total</code> - Total geocode requests</p> <p>Gauge Metrics - <code>cm_canvass_sessions_active</code> - Active canvass sessions - <code>cm_email_queue_size</code> - Email queue depth - <code>cm_geocode_queue_size</code> - Geocode queue depth - <code>cm_external_service_health</code> - Service health status (0/1)</p> <p>Histogram Metrics - <code>cm_geocode_duration_seconds</code> - Geocoding request duration - <code>http_request_duration_ms</code> - HTTP request duration</p> <p>Usage</p> <pre><code>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</code></pre> <p>HTTP Metrics</p> <p>Automatic tracking of: - Request count by method, route, status - Request duration percentiles - Active requests gauge</p>"},{"location":"v2/backend/utilities/#path-validation","title":"Path Validation","text":"<p>path-validator.ts (<code>utils/path-validator.ts</code>)</p> <p>Security utilities for path validation:</p> <p>Features - Null byte detection - Path traversal prevention (<code>../</code> patterns) - Encoded traversal detection (<code>%2e%2e</code>) - Path normalization</p> <pre><code>import { validatePath } from '../utils/path-validator';\n\nconst safe = validatePath(userInput);\nif (!safe) {\n throw new Error('Invalid path');\n}\n</code></pre>"},{"location":"v2/backend/utilities/#html-sanitization","title":"HTML Sanitization","text":"<p>sanitize.ts (<code>utils/sanitize.ts</code>)</p> <p>XSS prevention utilities:</p> <pre><code>import { escapeHtml } from '../utils/sanitize';\n\nconst safe = escapeHtml(userInput);\n// Escapes: < > & \" ' to HTML entities\n</code></pre>"},{"location":"v2/backend/utilities/#utility-functions-summary","title":"Utility Functions Summary","text":"Utility Function Purpose Spatial <code>isPointInPolygon()</code> Check if point is inside polygon <code>haversineDistance()</code> Calculate distance between points <code>calculateBounds()</code> Calculate bounding box <code>calculateCentroid()</code> Calculate center point <code>parseGeoJSON()</code> Parse GeoJSON to coordinates Logging <code>logger.info()</code> Log informational message <code>logger.error()</code> Log error message <code>logger.debug()</code> Log debug message Metrics <code>metrics.*.inc()</code> Increment counter <code>metrics.*.set()</code> Set gauge value <code>metrics.*.startTimer()</code> Start histogram timer Security <code>validatePath()</code> Validate file path safety <code>escapeHtml()</code> Sanitize HTML content"},{"location":"v2/backend/utilities/#configuration","title":"Configuration","text":"<p>Utilities are configured via environment variables:</p> <pre><code># Logging\nLOG_LEVEL=info # Minimum log level\nNODE_ENV=production # Environment mode\n\n# Metrics\nMETRICS_ENABLED=true # Enable Prometheus metrics\n</code></pre>"},{"location":"v2/backend/utilities/#related-documentation","title":"Related Documentation","text":"<ul> <li>Backend Overview</li> <li>Observability</li> <li>Security</li> <li>Map Features</li> <li>Monitoring Stack</li> </ul>"},{"location":"v2/contributing/","title":"Contributing to Changemaker Lite","text":"<p>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.</p>"},{"location":"v2/contributing/#welcome","title":"Welcome!","text":"<p>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.</p> <p>Our mission: Provide free, self-hosted tools for grassroots political campaigns to compete with well-funded opponents.</p>"},{"location":"v2/contributing/#ways-to-contribute","title":"Ways to Contribute","text":""},{"location":"v2/contributing/#1-code-contributions","title":"1. Code Contributions","text":"<p>Help build new features or fix bugs in:</p> <ul> <li>Backend API (TypeScript + Express + Prisma)</li> <li>Admin Frontend (React + Vite + Ant Design)</li> <li>Media API (TypeScript + Fastify + Drizzle)</li> <li>Infrastructure (Docker, Nginx, PostgreSQL, Redis)</li> </ul> <p>\u2192 Development Setup Guide</p>"},{"location":"v2/contributing/#2-documentation","title":"2. Documentation","text":"<p>Improve guides, tutorials, and API documentation:</p> <ul> <li>User guides - Help organizers use the platform</li> <li>Developer docs - API reference, architecture guides</li> <li>Tutorials - Step-by-step walkthroughs</li> <li>Translations - Localize docs for other languages</li> </ul> <p>\u2192 Documentation Guide</p>"},{"location":"v2/contributing/#3-bug-reports","title":"3. Bug Reports","text":"<p>Found a bug? Help us fix it:</p> <ul> <li>Search existing issues first</li> <li>Provide clear reproduction steps</li> <li>Include error messages and logs</li> <li>Test on latest version</li> </ul> <p>\u2192 Report a Bug</p>"},{"location":"v2/contributing/#4-feature-requests","title":"4. Feature Requests","text":"<p>Suggest new features or enhancements:</p> <ul> <li>Check roadmap first</li> <li>Describe the use case</li> <li>Explain why it's valuable</li> <li>Consider implementation complexity</li> </ul> <p>\u2192 Request a Feature</p>"},{"location":"v2/contributing/#5-testing","title":"5. Testing","text":"<p>Help test new features and releases:</p> <ul> <li>Test beta releases on staging</li> <li>Verify bug fixes</li> <li>Test migration procedures</li> <li>Report edge cases</li> </ul>"},{"location":"v2/contributing/#6-community-support","title":"6. Community Support","text":"<p>Help other users:</p> <ul> <li>Answer questions in Discussions</li> <li>Share your setup experiences</li> <li>Write blog posts or tutorials</li> <li>Present at community calls</li> </ul>"},{"location":"v2/contributing/#7-design","title":"7. Design","text":"<p>Improve user experience:</p> <ul> <li>UI/UX design mockups</li> <li>User flow improvements</li> <li>Accessibility enhancements</li> <li>Mobile responsiveness</li> </ul>"},{"location":"v2/contributing/#code-of-conduct","title":"Code of Conduct","text":"<p>Changemaker Lite is committed to providing a welcoming and inclusive environment for all contributors.</p> <p>Our values:</p> <ul> <li>Respect: Treat everyone with kindness and professionalism</li> <li>Inclusivity: Welcome contributors from all backgrounds</li> <li>Collaboration: Work together constructively</li> <li>Constructive feedback: Focus on improvement, not criticism</li> </ul> <p>\u2192 Full Code of Conduct</p> <p>Unacceptable behavior:</p> <ul> <li>Harassment, discrimination, or hate speech</li> <li>Personal attacks or trolling</li> <li>Publishing private information</li> <li>Spam or self-promotion</li> </ul> <p>Enforcement: Violations will result in warnings, temporary bans, or permanent bans depending on severity.</p> <p>Reporting: Email conduct@cmlite.org to report violations confidentially.</p>"},{"location":"v2/contributing/#getting-started","title":"Getting Started","text":""},{"location":"v2/contributing/#prerequisites","title":"Prerequisites","text":"<p>Before contributing code, ensure you have:</p> <ul> <li>Node.js 20+ installed</li> <li>Docker Desktop (or Docker + Docker Compose)</li> <li>Git for version control</li> <li>Code editor (VSCode recommended)</li> <li>GitHub account for pull requests</li> </ul>"},{"location":"v2/contributing/#quick-start","title":"Quick Start","text":"<ol> <li>Fork the repository on GitHub</li> <li>Clone your fork locally</li> <li>Set up development environment (guide)</li> <li>Find an issue to work on</li> <li>Create a branch for your changes</li> <li>Make your changes with tests</li> <li>Submit a pull request (guide)</li> </ol>"},{"location":"v2/contributing/#finding-issues-to-work-on","title":"Finding Issues to Work On","text":"<p>Good first issues: Look for issues tagged <code>good-first-issue</code> in GitHub Issues.</p> <p>Help wanted: Issues tagged <code>help-wanted</code> need contributors.</p> <p>By skill level: - <code>beginner</code> - Simple fixes, documentation - <code>intermediate</code> - Feature enhancements, refactoring - <code>advanced</code> - Architecture changes, performance optimization</p> <p>By area: - <code>backend</code> - API, database, services - <code>frontend</code> - React components, UI/UX - <code>infrastructure</code> - Docker, Nginx, deployment - <code>documentation</code> - Guides, tutorials, API docs</p> <p>\u2192 Browse Issues</p>"},{"location":"v2/contributing/#contribution-workflow","title":"Contribution Workflow","text":""},{"location":"v2/contributing/#1-claim-an-issue","title":"1. Claim an Issue","text":"<p>Before starting work:</p> <ol> <li>Comment on the issue: \"I'd like to work on this\"</li> <li>Wait for assignment: Maintainer will assign you</li> <li>Ask questions: Clarify requirements before coding</li> </ol> <p>Avoid Duplicate Work</p> <p>Always check if someone is already assigned before starting work.</p>"},{"location":"v2/contributing/#2-create-a-branch","title":"2. Create a Branch","text":"<pre><code># 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</code></pre> <p>Branch naming: - <code>feature/description</code> - New features - <code>fix/description</code> - Bug fixes - <code>docs/description</code> - Documentation - <code>refactor/description</code> - Code refactoring - <code>test/description</code> - Test additions</p>"},{"location":"v2/contributing/#3-make-changes","title":"3. Make Changes","text":"<p>Follow our coding standards:</p> <ul> <li>TypeScript: Strict mode, type all functions</li> <li>ESLint: Run <code>npm run lint</code> before committing</li> <li>Prettier: Auto-format with <code>npm run format</code></li> <li>Tests: Add tests for new features</li> <li>Comments: Document complex logic</li> </ul> <pre><code>// 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</code></pre>"},{"location":"v2/contributing/#4-test-your-changes","title":"4. Test Your Changes","text":"<pre><code># 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</code></pre>"},{"location":"v2/contributing/#5-commit-your-changes","title":"5. Commit Your Changes","text":"<p>Commit message format (Conventional Commits):</p> <pre><code>type(scope): short description\n\nLonger description (optional)\n\nFixes #123\n</code></pre> <p>Types: - <code>feat</code> - New feature - <code>fix</code> - Bug fix - <code>docs</code> - Documentation - <code>style</code> - Formatting, whitespace - <code>refactor</code> - Code restructuring - <code>test</code> - Test additions - <code>chore</code> - Build, tooling</p> <p>Examples: <pre><code>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</code></pre></p>"},{"location":"v2/contributing/#6-push-and-create-pull-request","title":"6. Push and Create Pull Request","text":"<pre><code># Push to your fork\ngit push origin feature/campaign-export\n\n# Create pull request on GitHub\n# Fill out the PR template\n</code></pre> <p>\u2192 Pull Request Guidelines</p>"},{"location":"v2/contributing/#7-code-review","title":"7. Code Review","text":"<p>After submitting your PR:</p> <ol> <li>Automated checks run (lint, tests, build)</li> <li>Maintainer review provides feedback</li> <li>Address feedback with new commits</li> <li>Request re-review after changes</li> <li>Merge after approval</li> </ol> <p>Be patient: Reviews may take 1-3 business days. If no response after 5 days, politely ping the maintainer.</p>"},{"location":"v2/contributing/#development-guidelines","title":"Development Guidelines","text":""},{"location":"v2/contributing/#code-style","title":"Code Style","text":"<p>TypeScript: <pre><code>// 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</code></pre></p> <p>React: <pre><code>// 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</code></pre></p> <p>Prisma: <pre><code>// 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</code></pre></p>"},{"location":"v2/contributing/#testing-guidelines","title":"Testing Guidelines","text":"<p>Unit tests: <pre><code>// 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</code></pre></p> <p>Integration tests: <pre><code>// 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</code></pre></p>"},{"location":"v2/contributing/#communication-channels","title":"Communication Channels","text":""},{"location":"v2/contributing/#github","title":"GitHub","text":"<ul> <li>Issues: Bug reports, feature requests</li> <li>Discussions: General questions, ideas</li> <li>Pull Requests: Code contributions</li> </ul>"},{"location":"v2/contributing/#email","title":"Email","text":"<ul> <li>General: hello@cmlite.org</li> <li>Security: security@cmlite.org</li> <li>Code of Conduct: conduct@cmlite.org</li> </ul>"},{"location":"v2/contributing/#community-calls","title":"Community Calls","text":"<ul> <li>Monthly Contributors Call: First Tuesday of month, 7pm UTC</li> <li>Quarterly Community Call: Last Friday of quarter, 6pm UTC</li> </ul> <p>\u2192 Join Calls</p>"},{"location":"v2/contributing/#recognition","title":"Recognition","text":"<p>We appreciate all contributors! Your name will be:</p> <ul> <li>Added to CONTRIBUTORS.md after first merged PR</li> <li>Listed in release notes for significant contributions</li> <li>Featured on website for major features</li> <li>Invited to community calls as a contributor</li> </ul>"},{"location":"v2/contributing/#hall-of-fame","title":"Hall of Fame","text":"<p>Top Contributors (all time):</p> <ol> <li>@contributor1 - 234 commits</li> <li>@contributor2 - 189 commits</li> <li>@contributor3 - 156 commits</li> </ol> <p>\u2192 Full Contributors List</p>"},{"location":"v2/contributing/#license","title":"License","text":"<p>By contributing to Changemaker Lite, you agree that your contributions will be licensed under the MIT License.</p> <p>This means: - Your code can be used by anyone - Attribution is required (copyright notice) - No warranty is provided</p> <p>See LICENSE for full terms.</p>"},{"location":"v2/contributing/#questions","title":"Questions?","text":"<ul> <li>Need help getting started? Ask in Discussions</li> <li>Have a question about an issue? Comment on the issue</li> <li>Stuck on development setup? Check Development Setup Guide</li> <li>Want to chat? Join our monthly contributors call</li> </ul>"},{"location":"v2/contributing/#related-documentation","title":"Related Documentation","text":"<ul> <li>Code of Conduct - Community standards</li> <li>Development Setup - Environment setup</li> <li>Pull Request Guidelines - PR process</li> <li>Roadmap - Future plans</li> </ul>"},{"location":"v2/contributing/#next-steps","title":"Next Steps","text":"<p>Ready to contribute?</p> <ol> <li>Read the Code of Conduct - Understand community standards</li> <li>Set up your environment - Install dependencies</li> <li>Find an issue - Pick something to work on</li> <li>Submit your first PR - Make your contribution</li> </ol> <p>Thank you for contributing to Changemaker Lite! Together, we're building tools for democratic change.</p>"},{"location":"v2/contributing/code-of-conduct/","title":"Code of Conduct","text":""},{"location":"v2/contributing/code-of-conduct/#our-pledge","title":"Our Pledge","text":"<p>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.</p> <p>We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community.</p>"},{"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":"<p>Behavior that contributes to a positive environment includes:</p> <ul> <li>Demonstrating empathy and kindness toward other people</li> <li>Being respectful of differing opinions, viewpoints, and experiences</li> <li>Giving and gracefully accepting constructive feedback</li> <li>Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience</li> <li>Focusing on what is best not just for us as individuals, but for the overall community</li> <li>Using welcoming and inclusive language</li> <li>Being patient and helpful with newcomers</li> <li>Showing appreciation for contributions, no matter how small</li> </ul>"},{"location":"v2/contributing/code-of-conduct/#examples-of-unacceptable-behavior","title":"Examples of Unacceptable Behavior","text":"<p>Behavior that will not be tolerated includes:</p> <ul> <li>Harassment: The use of sexualized language or imagery, and sexual attention or advances of any kind</li> <li>Trolling: Inflammatory, insulting, or derogatory comments, and personal or political attacks</li> <li>Discrimination: Discriminatory jokes, slurs, or language targeting any group</li> <li>Privacy violations: Publishing others' private information (addresses, phone numbers, email) without explicit permission</li> <li>Spam: Unsolicited promotion of products, services, or websites</li> <li>Doxxing: Publishing someone's personal information with malicious intent</li> <li>Intimidation: Threats of violence or deliberate intimidation</li> <li>Disruption: Deliberately disrupting discussions, meetings, or events</li> <li>Other conduct which could reasonably be considered inappropriate in a professional setting</li> </ul>"},{"location":"v2/contributing/code-of-conduct/#enforcement-responsibilities","title":"Enforcement Responsibilities","text":"<p>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.</p> <p>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.</p>"},{"location":"v2/contributing/code-of-conduct/#scope","title":"Scope","text":"<p>This Code of Conduct applies within all community spaces, including but not limited to:</p> <ul> <li>GitHub repositories: Issues, pull requests, discussions, wikis</li> <li>Communication channels: Email, community calls, chat platforms</li> <li>Events: Meetups, conferences, online gatherings</li> <li>Public spaces: When representing the project (social media, forums, etc.)</li> </ul> <p>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.</p>"},{"location":"v2/contributing/code-of-conduct/#enforcement","title":"Enforcement","text":""},{"location":"v2/contributing/code-of-conduct/#reporting-violations","title":"Reporting Violations","text":"<p>Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at conduct@cmlite.org.</p> <p>All complaints will be reviewed and investigated promptly and fairly.</p> <p>What to include in a report:</p> <ol> <li>Your contact information (for follow-up)</li> <li>Names of people involved (or pseudonyms)</li> <li>Description of behavior (what happened)</li> <li>When and where it occurred</li> <li>Links or screenshots (if applicable)</li> <li>Any witnesses</li> <li>Whether you've reported elsewhere (e.g., to GitHub)</li> </ol> <p>Confidentiality: All community leaders are obligated to respect the privacy and security of the reporter of any incident.</p>"},{"location":"v2/contributing/code-of-conduct/#investigation-process","title":"Investigation Process","text":"<p>Upon receiving a report:</p> <ol> <li>Acknowledgment: We will acknowledge receipt within 24 hours</li> <li>Investigation: We will review the report and gather additional information</li> <li>Decision: Community leaders will determine appropriate action</li> <li>Communication: We will inform the reporter of the outcome</li> <li>Action: We will enforce the decision</li> </ol> <p>Timeline: Most investigations complete within 7 days.</p>"},{"location":"v2/contributing/code-of-conduct/#enforcement-guidelines","title":"Enforcement Guidelines","text":"<p>Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct:</p>"},{"location":"v2/contributing/code-of-conduct/#1-correction","title":"1. Correction","text":"<p>Community Impact: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community.</p> <p>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.</p> <p>Example: - Using mildly offensive language - Being dismissive of others' contributions - Minor disruptions in discussions</p>"},{"location":"v2/contributing/code-of-conduct/#2-warning","title":"2. Warning","text":"<p>Community Impact: A violation through a single incident or series of actions.</p> <p>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.</p> <p>Example: - Repeated inappropriate language after correction - Personal attacks or insults - Sustained disruption of discussions</p> <p>Duration: 7-30 days, depending on severity.</p>"},{"location":"v2/contributing/code-of-conduct/#3-temporary-ban","title":"3. Temporary Ban","text":"<p>Community Impact: A serious violation of community standards, including sustained inappropriate behavior.</p> <p>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.</p> <p>Example: - Harassment or discrimination - Publishing private information - Threats or intimidation - Pattern of violations after warning</p> <p>Duration: 30 days to 6 months.</p>"},{"location":"v2/contributing/code-of-conduct/#4-permanent-ban","title":"4. Permanent Ban","text":"<p>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.</p> <p>Consequence: A permanent ban from any sort of public interaction within the community.</p> <p>Example: - Severe harassment or threats - Doxxing or privacy violations - Repeated violations after temporary ban - Violent or discriminatory content</p> <p>Duration: Permanent.</p>"},{"location":"v2/contributing/code-of-conduct/#appeals","title":"Appeals","text":"<p>If you believe an enforcement decision was made in error, you may appeal by:</p> <ol> <li>Emailing conduct@cmlite.org within 14 days of the decision</li> <li>Providing your reasoning for why the decision was incorrect</li> <li>Suggesting alternative resolution (if applicable)</li> </ol> <p>Appeals will be reviewed by a different community leader when possible. The appeal decision is final.</p> <p>Note: Appeals are not guaranteed to result in a changed decision.</p>"},{"location":"v2/contributing/code-of-conduct/#attribution","title":"Attribution","text":"<p>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.</p> <p>Community Impact Guidelines were inspired by Mozilla's code of conduct enforcement ladder.</p> <p>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.</p>"},{"location":"v2/contributing/code-of-conduct/#contact","title":"Contact","text":"<p>Enforcement Team: conduct@cmlite.org</p> <p>Project Maintainers: - Lead Maintainer: [Name] (email@cmlite.org) - Community Manager: [Name] (email@cmlite.org)</p> <p>Response Time: We aim to respond to all reports within 24 hours.</p>"},{"location":"v2/contributing/code-of-conduct/#acknowledgments","title":"Acknowledgments","text":"<p>We thank all contributors who help maintain a welcoming and inclusive community. Special thanks to:</p> <ul> <li>The Contributor Covenant team for the foundational code of conduct</li> <li>The Mozilla community for enforcement guidelines</li> <li>All community members who report violations to help keep our space safe</li> </ul>"},{"location":"v2/contributing/code-of-conduct/#version-history","title":"Version History","text":"<ul> <li>v2.1 (2026-02-13): Adopted from Contributor Covenant 2.1</li> <li>Future updates will be announced via GitHub Discussions</li> </ul> <p>Last updated: February 13, 2026</p> <p>By participating in this community, you agree to abide by this Code of Conduct.</p>"},{"location":"v2/contributing/development-setup/","title":"Development Setup","text":"<p>This guide will help you set up a complete development environment for contributing to Changemaker Lite V2.</p>"},{"location":"v2/contributing/development-setup/#prerequisites","title":"Prerequisites","text":"<p>Before beginning, ensure you have the following installed:</p>"},{"location":"v2/contributing/development-setup/#required-software","title":"Required Software","text":"<ul> <li> <p>Node.js 20+ (download) <pre><code>node --version # Should be v20.x.x or higher\n</code></pre></p> </li> <li> <p>npm 10+ (comes with Node.js) <pre><code>npm --version # Should be 10.x.x or higher\n</code></pre></p> </li> <li> <p>Docker Desktop (download) <pre><code>docker --version # Should be 20.10.x or higher\ndocker compose version # Should be 2.0.x or higher\n</code></pre></p> </li> <li> <p>Git (download) <pre><code>git --version # Should be 2.x.x or higher\n</code></pre></p> </li> </ul>"},{"location":"v2/contributing/development-setup/#recommended-software","title":"Recommended Software","text":"<ul> <li>VSCode (download) - Recommended code editor</li> <li>GitHub CLI (download) - Simplifies GitHub operations</li> <li>Postman or Thunder Client - API testing (Thunder Client is VSCode extension)</li> </ul>"},{"location":"v2/contributing/development-setup/#system-requirements","title":"System Requirements","text":"<ul> <li>Operating System: Linux, macOS, or Windows (with WSL2)</li> <li>RAM: 8GB minimum (16GB recommended)</li> <li>Disk Space: 20GB free space</li> <li>Internet: Required for npm packages and Docker images</li> </ul>"},{"location":"v2/contributing/development-setup/#fork-and-clone","title":"Fork and Clone","text":""},{"location":"v2/contributing/development-setup/#1-fork-the-repository","title":"1. Fork the Repository","text":"<ol> <li>Visit https://github.com/changemaker-lite/v2</li> <li>Click Fork button (top right)</li> <li>Select your GitHub account as the destination</li> </ol>"},{"location":"v2/contributing/development-setup/#2-clone-your-fork","title":"2. Clone Your Fork","text":"<pre><code># 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</code></pre>"},{"location":"v2/contributing/development-setup/#3-checkout-v2-branch","title":"3. Checkout V2 Branch","text":"<pre><code># Switch to v2 branch\ngit checkout v2\n\n# Verify you're on v2\ngit branch\n# Should show: * v2\n</code></pre>"},{"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":"<pre><code># Copy example environment file\ncp .env.example .env\n\n# Edit .env with your preferred editor\nnano .env # or: code .env (VSCode)\n</code></pre>"},{"location":"v2/contributing/development-setup/#2-configure-environment-variables","title":"2. Configure Environment Variables","text":"<p>Minimal development configuration:</p> <pre><code># 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</code></pre> <p>Generate secrets: <pre><code># 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</code></pre></p> <p>Security</p> <p>Use different secrets for JWT_ACCESS_SECRET, JWT_REFRESH_SECRET, and ENCRYPTION_KEY. Never commit .env to Git!</p>"},{"location":"v2/contributing/development-setup/#install-dependencies","title":"Install Dependencies","text":""},{"location":"v2/contributing/development-setup/#api-dependencies","title":"API Dependencies","text":"<pre><code>cd api\n\n# Install npm packages\nnpm install\n\n# Verify installation\nnpm list prisma # Should show @prisma/client and prisma packages\n</code></pre>"},{"location":"v2/contributing/development-setup/#admin-dependencies","title":"Admin Dependencies","text":"<pre><code>cd ../admin\n\n# Install npm packages\nnpm install\n\n# Verify installation\nnpm list react # Should show react 19.x.x\n</code></pre>"},{"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":"<p>Start PostgreSQL and Redis in Docker:</p> <pre><code>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</code></pre> <p>Run Prisma migrations:</p> <pre><code>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</code></pre> <p>Default admin credentials (from seed): - Email: <code>admin@example.com</code> - Password: <code>Admin123!</code></p> <p>Change Default Password</p> <p>Change the default admin password immediately after first login in development.</p>"},{"location":"v2/contributing/development-setup/#option-2-local-postgresql-advanced","title":"Option 2: Local PostgreSQL (Advanced)","text":"<p>If you have PostgreSQL 16 installed locally:</p> <pre><code># 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</code></pre>"},{"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":"<p>Run all services in Docker:</p> <pre><code># 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</code></pre> <p>Access points: - Admin: http://localhost:3000 - API: http://localhost:4000 - Media API: http://localhost:4100 - Prisma Studio: <code>cd api && npx prisma studio</code> - MailHog: http://localhost:8025</p>"},{"location":"v2/contributing/development-setup/#method-2-local-development-hot-reload","title":"Method 2: Local Development (Hot Reload)","text":"<p>Run services locally for faster development:</p> <p>Terminal 1 - API: <pre><code>cd api\nnpm run dev\n\n# Runs on http://localhost:4000\n# Hot reload enabled (nodemon)\n</code></pre></p> <p>Terminal 2 - Admin: <pre><code>cd admin\nnpm run dev\n\n# Runs on http://localhost:3000\n# Hot reload enabled (Vite HMR)\n</code></pre></p> <p>Terminal 3 - Media API (optional): <pre><code>cd api\nnpm run dev:media\n\n# Runs on http://localhost:4100\n# Hot reload enabled (nodemon)\n</code></pre></p> <p>Terminal 4 - Database (Docker): <pre><code># Keep PostgreSQL and Redis running\ndocker compose up -d v2-postgres redis\n</code></pre></p> <p>Recommended Workflow</p> <p>Use Method 2 (local) for frontend/backend development (faster hot reload). Use Method 1 (Docker) for testing full stack integration.</p>"},{"location":"v2/contributing/development-setup/#vscode-setup","title":"VSCode Setup","text":""},{"location":"v2/contributing/development-setup/#recommended-extensions","title":"Recommended Extensions","text":"<p>Install these VSCode extensions for better development experience:</p> <pre><code>{\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</code></pre>"},{"location":"v2/contributing/development-setup/#workspace-settings","title":"Workspace Settings","text":"<p>Create <code>.vscode/settings.json</code>:</p> <pre><code>{\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</code></pre>"},{"location":"v2/contributing/development-setup/#debug-configuration","title":"Debug Configuration","text":"<p>Create <code>.vscode/launch.json</code> for debugging:</p> <pre><code>{\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</code></pre>"},{"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":"<pre><code># 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</code></pre>"},{"location":"v2/contributing/development-setup/#2-code-style","title":"2. Code Style","text":"<p>Follow project conventions:</p> <p>Run linter: <pre><code>cd api && npm run lint # Backend\ncd admin && npm run lint # Frontend\n</code></pre></p> <p>Auto-fix: <pre><code>cd api && npm run lint:fix # Backend\ncd admin && npm run lint:fix # Frontend\n</code></pre></p> <p>Format code: <pre><code>cd api && npm run format # Backend\ncd admin && npm run format # Frontend\n</code></pre></p> <p>Type check: <pre><code>cd api && npx tsc --noEmit # Backend\ncd admin && npx tsc --noEmit # Frontend\n</code></pre></p>"},{"location":"v2/contributing/development-setup/#3-run-tests","title":"3. Run Tests","text":"<pre><code># 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</code></pre>"},{"location":"v2/contributing/development-setup/#4-test-your-changes","title":"4. Test Your Changes","text":"<p>Manual testing checklist:</p> <ul> <li> API endpoints work (use Postman/Thunder Client)</li> <li> Frontend renders correctly</li> <li> No console errors</li> <li> Works in Chrome, Firefox, Safari</li> <li> Responsive design (mobile, tablet, desktop)</li> <li> Accessibility (keyboard navigation, screen reader)</li> <li> Error handling (try invalid inputs)</li> <li> Loading states (try slow network)</li> </ul> <p>Integration testing: <pre><code># 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</code></pre></p>"},{"location":"v2/contributing/development-setup/#staying-synced-with-upstream","title":"Staying Synced with Upstream","text":"<p>Regularly sync your fork with the upstream repository:</p> <pre><code># 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</code></pre> <p>Rebase vs Merge</p> <p>Use <code>git rebase v2</code> for cleaner commit history. Use <code>git merge v2</code> if you're unsure about rebasing.</p>"},{"location":"v2/contributing/development-setup/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/contributing/development-setup/#port-conflicts","title":"Port Conflicts","text":"<p>Error: <code>Port 3000 already in use</code></p> <p>Solution: <pre><code># 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</code></pre></p>"},{"location":"v2/contributing/development-setup/#database-connection-errors","title":"Database Connection Errors","text":"<p>Error: <code>Can't reach database server at localhost:5433</code></p> <p>Solution: <pre><code># 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</code></pre></p>"},{"location":"v2/contributing/development-setup/#migration-errors","title":"Migration Errors","text":"<p>Error: <code>Migration failed</code></p> <p>Solution: <pre><code># 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</code></pre></p>"},{"location":"v2/contributing/development-setup/#dependency-installation-errors","title":"Dependency Installation Errors","text":"<p>Error: <code>npm install</code> fails</p> <p>Solution: <pre><code># 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</code></pre></p>"},{"location":"v2/contributing/development-setup/#docker-issues","title":"Docker Issues","text":"<p>Error: Docker daemon not running</p> <p>Solution: - macOS/Windows: Start Docker Desktop - Linux: <code>sudo systemctl start docker</code></p> <p>Error: Permission denied (Docker)</p> <p>Solution (Linux): <pre><code># Add user to docker group\nsudo usermod -aG docker $USER\n\n# Log out and back in, or:\nnewgrp docker\n</code></pre></p>"},{"location":"v2/contributing/development-setup/#next-steps","title":"Next Steps","text":"<p>Now that your environment is set up:</p> <ol> <li>Find an issue to work on</li> <li>Review code style guidelines</li> <li>Create your first PR</li> <li>Join the community</li> </ol>"},{"location":"v2/contributing/development-setup/#related-documentation","title":"Related Documentation","text":"<ul> <li>Contributing Guide - Contribution overview</li> <li>Pull Request Guidelines - PR process</li> <li>Code of Conduct - Community standards</li> <li>Architecture - System design</li> </ul>"},{"location":"v2/contributing/development-setup/#getting-help","title":"Getting Help","text":"<p>Stuck on setup? Ask for help:</p> <ul> <li>GitHub Discussions: Ask a question</li> <li>Discord: Join our server</li> <li>Email: dev@cmlite.org</li> </ul> <p>Happy coding! \ud83d\ude80</p>"},{"location":"v2/contributing/pull-requests/","title":"Pull Request Guidelines","text":"<p>This guide covers the complete pull request (PR) process for contributing code to Changemaker Lite V2, from creation to merge.</p>"},{"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":"<p>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</p> <p>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 <code>v2</code> branch</p> <p>Avoid Wasted Effort</p> <p>Always create an issue and get approval before spending time on a large feature. Maintainers may have alternative approaches or priorities.</p>"},{"location":"v2/contributing/pull-requests/#2-test-your-changes","title":"2. Test Your Changes","text":"<p>Run all checks locally before submitting:</p> <pre><code># 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</code></pre> <p>All checks must pass before submitting PR.</p>"},{"location":"v2/contributing/pull-requests/#3-update-documentation","title":"3. Update Documentation","text":"<p>If your changes affect:</p> <ul> <li>API endpoints: Update API reference</li> <li>User features: Update user guides</li> <li>Environment variables: Update <code>.env.example</code></li> <li>Database schema: Update database docs</li> <li>Configuration: Update deployment guides</li> </ul> <p>Documentation is Required</p> <p>PRs with new features will not be merged without corresponding documentation updates.</p>"},{"location":"v2/contributing/pull-requests/#pr-title-format","title":"PR Title Format","text":"<p>Use Conventional Commits format:</p> <pre><code>type(scope): short description\n</code></pre>"},{"location":"v2/contributing/pull-requests/#types","title":"Types","text":"Type When to Use <code>feat</code> New feature for users <code>fix</code> Bug fix <code>docs</code> Documentation changes <code>style</code> Code formatting (no behavior change) <code>refactor</code> Code restructuring (no behavior change) <code>perf</code> Performance improvements <code>test</code> Test additions or fixes <code>chore</code> Build process, tooling, dependencies <code>ci</code> CI/CD configuration <code>revert</code> Reverting a previous commit"},{"location":"v2/contributing/pull-requests/#scopes","title":"Scopes","text":"<p>Common scopes by area:</p> <p>Backend: - <code>api</code> - General API changes - <code>auth</code> - Authentication/authorization - <code>campaigns</code> - Campaign module - <code>locations</code> - Location module - <code>shifts</code> - Shift module - <code>canvass</code> - Canvassing module - <code>email</code> - Email sending - <code>database</code> - Database schema/migrations</p> <p>Frontend: - <code>admin</code> - Admin pages - <code>public</code> - Public pages - <code>volunteer</code> - Volunteer portal - <code>components</code> - React components - <code>store</code> - Zustand stores - <code>ui</code> - UI/UX changes</p> <p>Infrastructure: - <code>docker</code> - Docker/Docker Compose - <code>nginx</code> - Nginx configuration - <code>monitoring</code> - Prometheus/Grafana - <code>deployment</code> - Deployment scripts/config</p>"},{"location":"v2/contributing/pull-requests/#examples","title":"Examples","text":"<p>Good titles: - \u2705 <code>feat(campaigns): add campaign export to CSV</code> - \u2705 <code>fix(geocoding): handle null responses from Nominatim</code> - \u2705 <code>docs(api): document campaign endpoints</code> - \u2705 <code>refactor(auth): extract JWT middleware to separate file</code> - \u2705 <code>perf(locations): add database index on postalCode</code></p> <p>Bad titles: - \u274c <code>Update campaigns.tsx</code> (too vague) - \u274c <code>Bug fix</code> (no scope or description) - \u274c <code>WIP: New feature</code> (don't submit WIP PRs) - \u274c <code>Fixed everything</code> (not descriptive)</p>"},{"location":"v2/contributing/pull-requests/#pr-description-template","title":"PR Description Template","text":"<p>Use this template for your PR description:</p> <pre><code>## 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</code></pre>"},{"location":"v2/contributing/pull-requests/#example-pr-description","title":"Example PR Description","text":"<pre><code>## 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</code></pre>"},{"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":"<pre><code># 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</code></pre>"},{"location":"v2/contributing/pull-requests/#2-open-pr-on-github","title":"2. Open PR on GitHub","text":"<ol> <li>Go to your fork on GitHub</li> <li>Click \"Pull requests\" tab</li> <li>Click \"New pull request\"</li> <li>Base repository: <code>changemaker-lite/v2</code> base: <code>v2</code></li> <li>Head repository: <code>YOUR-USERNAME/changemaker-lite</code> compare: <code>feature/your-feature-name</code></li> <li>Click \"Create pull request\"</li> <li>Fill out the PR template (see above)</li> <li>Click \"Create pull request\"</li> </ol>"},{"location":"v2/contributing/pull-requests/#3-request-reviewers","title":"3. Request Reviewers","text":"<ul> <li>PRs are automatically assigned to maintainers</li> <li>You can request specific reviewers if you know who to ask</li> <li>For urgent PRs, mention <code>@changemaker-lite/maintainers</code></li> </ul>"},{"location":"v2/contributing/pull-requests/#code-review-process","title":"Code Review Process","text":""},{"location":"v2/contributing/pull-requests/#automated-checks","title":"Automated Checks","text":"<p>After submitting, CI/CD runs these checks:</p> <ol> <li>Lint: ESLint rules</li> <li>Type Check: TypeScript compilation</li> <li>Tests: Unit + integration tests</li> <li>Build: Production build</li> <li>Security: Dependency vulnerability scan</li> </ol> <p>Status badges appear on your PR:</p> <ul> <li>\u2705 Green checkmark: All checks passed</li> <li>\u274c Red X: Some checks failed</li> <li>\ud83d\udfe1 Yellow dot: Checks in progress</li> </ul> <p>Fix failing checks before requesting review.</p>"},{"location":"v2/contributing/pull-requests/#maintainer-review","title":"Maintainer Review","text":"<p>A maintainer will review your code and provide feedback:</p> <p>Review categories:</p> <ol> <li>Code quality:</li> <li>Follows code style guidelines</li> <li>No unnecessary complexity</li> <li>Proper error handling</li> <li> <p>No security vulnerabilities</p> </li> <li> <p>Functionality:</p> </li> <li>Solves the problem correctly</li> <li>Edge cases handled</li> <li> <p>No regressions</p> </li> <li> <p>Tests:</p> </li> <li>Adequate test coverage (>80%)</li> <li>Tests are meaningful</li> <li> <p>Tests pass consistently</p> </li> <li> <p>Documentation:</p> </li> <li>Code comments for complex logic</li> <li>API documentation updated</li> <li>User guide updated (if needed)</li> </ol>"},{"location":"v2/contributing/pull-requests/#review-outcomes","title":"Review Outcomes","text":"<p>Approved \u2705: - Maintainer approves PR - Ready to merge (after squash)</p> <p>Request Changes \ud83d\udd04: - Maintainer requests modifications - Address feedback and push new commits - Re-request review after changes</p> <p>Comment \ud83d\udcac: - Feedback without blocking merge - Optional to address</p>"},{"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":"<ul> <li>Understand the requested change</li> <li>Ask clarifying questions if unclear</li> <li>Don't take criticism personally (it's about code, not you)</li> </ul>"},{"location":"v2/contributing/pull-requests/#2-make-changes","title":"2. Make Changes","text":"<pre><code># 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</code></pre> <p>Commits are added to existing PR automatically.</p>"},{"location":"v2/contributing/pull-requests/#3-respond-to-comments","title":"3. Respond to Comments","text":"<ul> <li>Acknowledge feedback: \"Good catch, fixed in abc1234\"</li> <li>Explain changes: \"Refactored this to use a switch statement instead\"</li> <li>Ask questions: \"I'm not sure how to handle X, suggestions?\"</li> <li>Mark resolved: Click \"Resolve conversation\" after addressing</li> </ul>"},{"location":"v2/contributing/pull-requests/#4-re-request-review","title":"4. Re-Request Review","text":"<p>After addressing all feedback:</p> <ol> <li>Click \"Reviewers\" section</li> <li>Click circular arrow next to reviewer's name</li> <li>Or comment <code>@reviewer Ready for re-review</code></li> </ol>"},{"location":"v2/contributing/pull-requests/#common-review-feedback","title":"Common Review Feedback","text":""},{"location":"v2/contributing/pull-requests/#code-quality-issues","title":"Code Quality Issues","text":"<p>Issue: Large function with too many responsibilities</p> <p>Feedback:</p> <p>This function is doing too much. Can you extract the geocoding logic into a separate function?</p> <p>Fix: <pre><code>// 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</code></pre></p> <p>Issue: Magic numbers or strings</p> <p>Feedback:</p> <p>What does <code>30</code> represent here? Use a named constant.</p> <p>Fix: <pre><code>// Before\nif (visits.length >= 30) { }\n\n// After\nconst VISIT_RATE_LIMIT = 30;\nif (visits.length >= VISIT_RATE_LIMIT) { }\n</code></pre></p> <p>Issue: Missing error handling</p> <p>Feedback:</p> <p>What happens if the API call fails? Add error handling.</p> <p>Fix: <pre><code>// 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</code></pre></p>"},{"location":"v2/contributing/pull-requests/#test-coverage-issues","title":"Test Coverage Issues","text":"<p>Issue: Missing test for edge case</p> <p>Feedback:</p> <p>Add a test for when postal code is invalid.</p> <p>Fix: <pre><code>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</code></pre></p>"},{"location":"v2/contributing/pull-requests/#documentation-issues","title":"Documentation Issues","text":"<p>Issue: Missing API documentation</p> <p>Feedback:</p> <p>Add this endpoint to the API reference docs.</p> <p>Fix: Update <code>docs/v2/api-reference/campaigns.md</code> with new endpoint.</p>"},{"location":"v2/contributing/pull-requests/#performance-issues","title":"Performance Issues","text":"<p>Issue: N+1 query problem</p> <p>Feedback:</p> <p>This causes N+1 queries. Use Prisma <code>include</code> to join.</p> <p>Fix: <pre><code>// 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</code></pre></p>"},{"location":"v2/contributing/pull-requests/#merge-process","title":"Merge Process","text":""},{"location":"v2/contributing/pull-requests/#squash-and-merge","title":"Squash and Merge","text":"<p>Changemaker Lite uses squash and merge for all PRs:</p> <ol> <li>Maintainer clicks \"Squash and merge\"</li> <li>All commits in PR are squashed into one commit</li> <li>Commit message = PR title + description summary</li> <li>Merged to <code>v2</code> branch</li> </ol> <p>Why squash? - Clean linear history - Easier to revert if needed - No messy \"WIP\" or \"fix typo\" commits</p>"},{"location":"v2/contributing/pull-requests/#after-merge","title":"After Merge","text":"<p>Once your PR is merged:</p> <ol> <li>Celebrate! \ud83c\udf89 You've contributed to Changemaker Lite</li> <li>Update your fork: <pre><code>git checkout v2\ngit pull upstream v2\ngit push origin v2\n</code></pre></li> <li>Delete feature branch (optional): <pre><code>git branch -d feature/your-feature-name\ngit push origin --delete feature/your-feature-name\n</code></pre></li> <li>Update issue: GitHub auto-closes issue with <code>Fixes #N</code></li> <li>Check release notes: Your contribution will be mentioned in next release</li> </ol>"},{"location":"v2/contributing/pull-requests/#pr-checklist","title":"PR Checklist","text":"<p>Use this before submitting:</p>"},{"location":"v2/contributing/pull-requests/#pre-submission","title":"Pre-Submission","text":"<ul> <li> Issue created and approved by maintainer</li> <li> Branch created from latest <code>v2</code></li> <li> Changes implemented following code style</li> <li> Self-review - read your own code critically</li> <li> Manual testing - verify changes work as expected</li> </ul>"},{"location":"v2/contributing/pull-requests/#code-quality","title":"Code Quality","text":"<ul> <li> TypeScript: No type errors (<code>npx tsc --noEmit</code>)</li> <li> Linting: No lint errors (<code>npm run lint</code>)</li> <li> Formatting: Code formatted (<code>npm run format</code>)</li> <li> No console logs: Remove debug statements</li> <li> No commented code: Remove old code</li> <li> Error handling: All errors caught and logged</li> </ul>"},{"location":"v2/contributing/pull-requests/#tests","title":"Tests","text":"<ul> <li> Unit tests: Added/updated tests</li> <li> Tests pass: <code>npm test</code> succeeds</li> <li> Coverage: Maintained or improved (>80%)</li> <li> Integration tests: Added if needed</li> <li> Edge cases: Tested invalid inputs</li> </ul>"},{"location":"v2/contributing/pull-requests/#documentation","title":"Documentation","text":"<ul> <li> Code comments: Complex logic documented</li> <li> API docs: New endpoints documented</li> <li> User docs: User guide updated (if user-facing)</li> <li> README: Updated if needed</li> <li> .env.example: New env vars added</li> </ul>"},{"location":"v2/contributing/pull-requests/#ui-if-applicable","title":"UI (if applicable)","text":"<ul> <li> Responsive: Works on mobile/tablet/desktop</li> <li> Accessibility: Keyboard navigation works</li> <li> Browser testing: Works in Chrome, Firefox, Safari</li> <li> Loading states: Spinners for async operations</li> <li> Error states: Error messages shown to user</li> <li> Screenshots: Included in PR description</li> </ul>"},{"location":"v2/contributing/pull-requests/#final-checks","title":"Final Checks","text":"<ul> <li> CI passing: All automated checks green</li> <li> PR template: Description complete</li> <li> Commit messages: Follow conventional commits</li> <li> No merge conflicts: Branch rebased/merged with <code>v2</code></li> <li> Reviewers requested: Maintainers notified</li> </ul>"},{"location":"v2/contributing/pull-requests/#troubleshooting-prs","title":"Troubleshooting PRs","text":""},{"location":"v2/contributing/pull-requests/#ci-checks-failing","title":"CI Checks Failing","text":"<p>Lint failures: <pre><code>cd api && npm run lint:fix\ncd admin && npm run lint:fix\ngit add . && git commit -m \"chore: fix lint errors\" && git push\n</code></pre></p> <p>Type errors: <pre><code>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</code></pre></p> <p>Test failures: <pre><code>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</code></pre></p>"},{"location":"v2/contributing/pull-requests/#merge-conflicts","title":"Merge Conflicts","text":"<p>Resolving conflicts: <pre><code># 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</code></pre></p>"},{"location":"v2/contributing/pull-requests/#pr-not-getting-reviewed","title":"PR Not Getting Reviewed","text":"<p>If no review after 5 business days:</p> <ol> <li>Check CI: Ensure all checks pass</li> <li>Ping maintainer: Comment \"@changemaker-lite/maintainers Friendly ping for review\"</li> <li>Join Discord: Ask in #contributors channel</li> <li>Email: dev@cmlite.org for urgent PRs</li> </ol> <p>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</p>"},{"location":"v2/contributing/pull-requests/#related-documentation","title":"Related Documentation","text":"<ul> <li>Contributing Guide - Overview</li> <li>Development Setup - Environment setup</li> <li>Code of Conduct - Community standards</li> <li>Roadmap - Future plans</li> </ul>"},{"location":"v2/contributing/pull-requests/#questions","title":"Questions?","text":"<ul> <li>Discord: #contributors channel</li> <li>Discussions: Ask in Q&A</li> <li>Email: dev@cmlite.org</li> </ul> <p>Thank you for contributing! Every PR helps make Changemaker Lite better. \ud83d\ude80</p>"},{"location":"v2/contributing/roadmap/","title":"Changemaker Lite V2 Roadmap","text":"<p>This roadmap outlines the development journey of Changemaker Lite V2, including completed phases, current work, and future plans.</p>"},{"location":"v2/contributing/roadmap/#overview","title":"Overview","text":"<p>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.</p> <p>Current Status: \u2705 Phase 1-14 Complete | \ud83d\udea7 Phase 15 In Progress</p>"},{"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":"<p>Timeline: January 2025</p> <p>Deliverables: - Initialized <code>api/</code> 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 <code>admin/</code> with Vite + React + Ant Design - Created Docker Compose orchestration - Wrote <code>.env.example</code> with 100+ variables - Backed up V1 to <code>docker-compose.v1.yml</code></p> <p>Key Achievements: - Clean-room architecture established - Type-safe foundation with TypeScript - Scalable project structure</p>"},{"location":"v2/contributing/roadmap/#phase-2-auth-user-management-complete","title":"Phase 2: Auth + User Management \u2705 COMPLETE","text":"<p>Timeline: January 2025</p> <p>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)</p> <p>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)</p>"},{"location":"v2/contributing/roadmap/#phase-3-admin-gui-foundation-complete","title":"Phase 3: Admin GUI Foundation \u2705 COMPLETE","text":"<p>Timeline: January 2025</p> <p>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)</p> <p>Key Achievements: - Automatic token refresh (seamless UX) - Role-based sidebar navigation - Responsive Ant Design components</p>"},{"location":"v2/contributing/roadmap/#phase-4-influence-campaigns-complete","title":"Phase 4: Influence \u2014 Campaigns \u2705 COMPLETE","text":"<p>Timeline: January 2025</p> <p>Deliverables: - Campaign Zod schemas - Campaign service (CRUD, slug generation, toggle highlighting) - Campaign admin routes - CampaignsPage (table, filters, CRUD modals) - Feature flag integration</p> <p>Key Achievements: - Unique slug generation - Highlighted campaign toggle - Response wall enable/disable per campaign</p>"},{"location":"v2/contributing/roadmap/#phase-5-influence-representatives-postal-codes-complete","title":"Phase 5: Influence \u2014 Representatives + Postal Codes \u2705 COMPLETE","text":"<p>Timeline: January 2025</p> <p>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)</p> <p>Key Achievements: - Redis cache (60min TTL, ~20ms lookup) - In-memory rate limiter (Represent API limit) - Cache stats dashboard (total, by level, by party)</p>"},{"location":"v2/contributing/roadmap/#phase-6-influence-email-sending-complete","title":"Phase 6: Influence \u2014 Email Sending \u2705 COMPLETE","text":"<p>Timeline: January 2025</p> <p>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)</p> <p>Key Achievements: - Async email processing (BullMQ) - Email test mode (MailHog) - Rate limiting (30 req/hour per IP) - Job retry with exponential backoff</p>"},{"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":"<p>Timeline: January-February 2025</p> <p>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)</p> <p>Key Achievements: - Moderation workflow (PENDING \u2192 APPROVED/REJECTED) - Upvote deduplication (IP address + user ID) - Public campaign discovery</p>"},{"location":"v2/contributing/roadmap/#phase-8-map-locations-complete","title":"Phase 8: Map \u2014 Locations \u2705 COMPLETE","text":"<p>Timeline: February 2025</p> <p>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</p> <p>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)</p>"},{"location":"v2/contributing/roadmap/#phase-9-map-shifts-complete","title":"Phase 9: Map \u2014 Shifts \u2705 COMPLETE","text":"<p>Timeline: February 2025</p> <p>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</p> <p>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)</p>"},{"location":"v2/contributing/roadmap/#phase-10-walk-sheets-qr-codes-complete","title":"Phase 10: Walk Sheets & QR Codes \u2705 COMPLETE","text":"<p>Timeline: February 2025</p> <p>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</p> <p>Key Achievements: - QR codes encode location data (address, coordinates, notes) - Print-optimized CSS (page breaks, hide buttons) - Cut-specific walk sheets (filter by cut)</p>"},{"location":"v2/contributing/roadmap/#phase-11-listmonk-integration-complete","title":"Phase 11: Listmonk Integration \u2705 COMPLETE","text":"<p>Timeline: February 2025</p> <p>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 (<code>LISTMONK_SYNC_ENABLED</code>)</p> <p>Key Achievements: - Newsletter integration (advocacy campaigns \u2192 subscriber lists) - Automatic list creation/sync - Proton Mail SMTP configuration (listmonk-init auto-configures)</p>"},{"location":"v2/contributing/roadmap/#phase-12-landing-page-builder-complete","title":"Phase 12: Landing Page Builder \u2705 COMPLETE","text":"<p>Timeline: February 2025</p> <p>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)</p> <p>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</p>"},{"location":"v2/contributing/roadmap/#phase-13-volunteer-canvassing-system-complete","title":"Phase 13: Volunteer Canvassing System \u2705 COMPLETE","text":"<p>Timeline: February 2025</p> <p>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)</p> <p>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)</p>"},{"location":"v2/contributing/roadmap/#phase-14-monitoring-devops-complete","title":"Phase 14: Monitoring + DevOps \u2705 COMPLETE","text":"<p>Timeline: February 2026</p> <p>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 <code>scripts/legacy/</code></p> <p>Prometheus Metrics: - 12 domain-specific <code>cm_*</code> metrics (emails, auth, canvass, services, etc.) - Instrumented modules (email-queue, auth, campaigns, responses, canvass, shifts, services) - HTTP request metrics (duration, count, errors)</p> <p>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)</p> <p>Docker Healthchecks: - 7 services with healthchecks (API, admin, nginx, NocoDB, n8n, Gitea, Listmonk)</p> <p>Backup: - <code>scripts/backup.sh</code> (V2 PostgreSQL + Listmonk + uploads archive) - Manifest with timestamps, sizes, SHA256 checksums - Configurable retention (default 30 days) - Optional S3 upload (--s3 flag)</p> <p>Key Achievements: - Self-hosted tunnel alternative (Pangolin replaces Cloudflare) - Comprehensive observability (Prometheus + Grafana) - Production-ready monitoring stack - Automated backup procedures</p>"},{"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":"<p>Timeline: February-March 2026</p> <p>Goals: - Comprehensive testing (unit, integration, E2E) - Performance optimization - Security hardening - Documentation polish - Bug fixes</p> <p>Planned Deliverables:</p> <p>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</p> <p>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</p> <p>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</p> <p>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)</p> <p>Bug Fixes: - [ ] Review and fix open GitHub issues - [ ] Fix reported bugs (priority: critical > high > medium > low) - [ ] Address edge cases - [ ] Improve error messages</p> <p>Polish: - [ ] UI/UX refinements (spacing, alignment, colors) - [ ] Accessibility improvements (keyboard nav, screen reader) - [ ] Mobile responsiveness fixes - [ ] Loading states improvements - [ ] Error state improvements</p> <p>Progress: 20% (security audit complete, NAR import complete, media upload complete)</p>"},{"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":"<p>Goal: Support multiple organizations on single instance</p> <p>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</p> <p>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)</p> <p>Timeline: 2-3 months (tentative Q2 2026)</p>"},{"location":"v2/contributing/roadmap/#phase-17-mobile-apps-planned","title":"Phase 17: Mobile Apps (Planned)","text":"<p>Goal: Native iOS and Android apps for volunteers</p> <p>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)</p> <p>Technical Stack: - React Native + Expo - AsyncStorage for offline data - React Query for sync - Expo Notifications - Expo Camera</p> <p>Timeline: 3-4 months (tentative Q3 2026)</p>"},{"location":"v2/contributing/roadmap/#phase-18-advanced-analytics-planned","title":"Phase 18: Advanced Analytics (Planned)","text":"<p>Goal: Campaign performance and volunteer metrics</p> <p>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)</p> <p>Technical Stack: - Prisma aggregations - Chart.js or Recharts - CSV/Excel export - Optional: Metabase integration</p> <p>Timeline: 2 months (tentative Q4 2026)</p>"},{"location":"v2/contributing/roadmap/#phase-19-ai-integration-exploratory","title":"Phase 19: AI Integration (Exploratory)","text":"<p>Goal: AI-powered features for campaign optimization</p> <p>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</p> <p>Technical Considerations: - OpenAI API integration (cost considerations) - Privacy concerns (user data in AI models) - Ethical AI usage guidelines - Opt-in for AI features</p> <p>Timeline: TBD (community feedback needed)</p>"},{"location":"v2/contributing/roadmap/#phase-20-additional-integrations-planned","title":"Phase 20: Additional Integrations (Planned)","text":"<p>Goal: Connect to other campaign tools</p> <p>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</p> <p>Timeline: Ongoing (community-driven priorities)</p>"},{"location":"v2/contributing/roadmap/#feature-requests","title":"Feature Requests","text":"<p>Have an idea for a new feature? We'd love to hear it!</p>"},{"location":"v2/contributing/roadmap/#how-to-request","title":"How to Request","text":"<ol> <li>Search existing requests: Check Discussions</li> <li>Create new discussion: Start a discussion</li> <li>Provide details:</li> <li>Problem: What problem does this solve?</li> <li>Use case: Who would use this feature?</li> <li>Implementation ideas: How might it work?</li> <li>Alternatives: What workarounds exist today?</li> </ol>"},{"location":"v2/contributing/roadmap/#prioritization-process","title":"Prioritization Process","text":"<p>Features are prioritized based on:</p> <ol> <li>Impact: How many users benefit?</li> <li>Effort: How complex to implement?</li> <li>Strategic fit: Aligns with mission?</li> <li>Community votes: Upvote discussions</li> <li>Funding: Sponsored development</li> </ol> <p>High-priority features: - Requested by many users - Low implementation effort - Core to mission (campaign advocacy, volunteer management)</p> <p>Low-priority features: - Niche use cases - High complexity - Available via integrations</p>"},{"location":"v2/contributing/roadmap/#community-voting","title":"Community Voting","text":"<p>Upvote feature requests in GitHub Discussions:</p> <ol> <li>Go to Ideas category</li> <li>Click \ud83d\udc4d on discussions you want</li> <li>Comment with your use case</li> </ol> <p>Most-upvoted features are considered for roadmap.</p>"},{"location":"v2/contributing/roadmap/#contribution-opportunities","title":"Contribution Opportunities","text":"<p>Want to contribute to the roadmap?</p>"},{"location":"v2/contributing/roadmap/#code-contributions","title":"Code Contributions","text":"<ul> <li>Phase 15 (Testing): Write integration tests, E2E tests</li> <li>Phase 15 (Performance): Optimize queries, reduce bundle size</li> <li>Phase 15 (Documentation): Improve guides, add tutorials</li> </ul> <p>\u2192 Find Issues</p>"},{"location":"v2/contributing/roadmap/#design-contributions","title":"Design Contributions","text":"<ul> <li>UI/UX mockups: Design future features</li> <li>User research: Interview campaign organizers</li> <li>Accessibility audit: Test with screen readers</li> </ul>"},{"location":"v2/contributing/roadmap/#documentation-contributions","title":"Documentation Contributions","text":"<ul> <li>User guides: Write how-to guides</li> <li>Video tutorials: Create walkthrough videos</li> <li>Translations: Translate docs to other languages</li> </ul>"},{"location":"v2/contributing/roadmap/#sponsorship","title":"Sponsorship","text":"<p>Support development of specific features:</p> <ul> <li>Individual sponsors: $10/month (GitHub Sponsors)</li> <li>Organization sponsors: $500+/month (custom features, priority support)</li> <li>One-time donations: Sponsor specific features</li> </ul> <p>\u2192 Sponsor on GitHub</p>"},{"location":"v2/contributing/roadmap/#release-schedule","title":"Release Schedule","text":""},{"location":"v2/contributing/roadmap/#version-numbering","title":"Version Numbering","text":"<p>Changemaker Lite uses Semantic Versioning:</p> <ul> <li>Major (1.0.0): Breaking changes</li> <li>Minor (1.1.0): New features (backward compatible)</li> <li>Patch (1.1.1): Bug fixes</li> </ul> <p>Current version: <code>2.0.0-beta.1</code> (Phase 15 in progress)</p>"},{"location":"v2/contributing/roadmap/#release-cycle","title":"Release Cycle","text":"<p>Major releases: 6-12 months (major new features, breaking changes)</p> <p>Minor releases: 1-2 months (new features, no breaking changes)</p> <p>Patch releases: 1-2 weeks (bug fixes, security patches)</p>"},{"location":"v2/contributing/roadmap/#upcoming-releases","title":"Upcoming Releases","text":"<p>v2.0.0 (stable release): - Target: March 2026 - Requires: Phase 15 complete (testing, polish) - Breaking changes from beta: TBD</p> <p>v2.1.0: - Target: May 2026 - Features: TBD based on community feedback</p> <p>v2.2.0: - Target: July 2026 - Features: Possibly multi-tenancy (Phase 16)</p>"},{"location":"v2/contributing/roadmap/#long-term-vision","title":"Long-Term Vision","text":"<p>Mission: Provide free, self-hosted tools for grassroots political campaigns.</p> <p>5-Year Vision (2026-2031):</p> <ol> <li>Year 1 (2026): V2 stable, 100+ organizations using Changemaker Lite</li> <li>Year 2 (2027): Multi-tenancy, mobile apps, 500+ organizations</li> <li>Year 3 (2028): Advanced analytics, AI features, 1000+ organizations</li> <li>Year 4 (2029): Ecosystem of integrations, international campaigns</li> <li>Year 5 (2030): Changemaker Lite as standard platform for grassroots advocacy</li> </ol> <p>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)</p>"},{"location":"v2/contributing/roadmap/#breaking-changes-policy","title":"Breaking Changes Policy","text":""},{"location":"v2/contributing/roadmap/#commitment","title":"Commitment","text":"<p>We strive to minimize breaking changes in V2 minor releases. When breaking changes are necessary:</p> <ol> <li>Advance notice: Announced 2 releases prior (e.g., deprecation in v2.1.0, removal in v2.3.0)</li> <li>Migration guide: Detailed upgrade guide provided</li> <li>Deprecation warnings: Console warnings in code</li> <li>Major version bumps: Breaking changes only in major releases (v2\u2192v3)</li> </ol>"},{"location":"v2/contributing/roadmap/#deprecation-process","title":"Deprecation Process","text":"<ol> <li>Deprecate: Mark feature as deprecated (console warnings)</li> <li>Announce: Publish deprecation notice in release notes</li> <li>Wait: Keep deprecated feature for 2 releases minimum</li> <li>Remove: Remove in next major version</li> </ol> <p>Example: - v2.1.0: Deprecate <code>/api/old-endpoint</code> (with warnings) - v2.2.0: Still supported, warnings continue - v2.3.0: Still supported, migration guide published - v3.0.0: Removed (breaking change)</p>"},{"location":"v2/contributing/roadmap/#related-documentation","title":"Related Documentation","text":"<ul> <li>Contributing Guide - How to contribute</li> <li>Development Setup - Environment setup</li> <li>Pull Request Guidelines - PR process</li> <li>V2 Plan - Original roadmap document</li> </ul>"},{"location":"v2/contributing/roadmap/#feedback","title":"Feedback","text":"<p>Have feedback on the roadmap?</p> <ul> <li>Discuss features: GitHub Discussions</li> <li>Report priorities: Email roadmap@cmlite.org</li> <li>Vote on features: Upvote discussions</li> </ul> <p>Together, we're building the future of grassroots political campaigns! \ud83d\ude80</p>"},{"location":"v2/database/","title":"Database Documentation","text":""},{"location":"v2/database/#overview","title":"Overview","text":"<p>Changemaker Lite V2 uses a dual ORM architecture with PostgreSQL 16 as the backing database:</p> <ul> <li>Prisma ORM (Express API, port 4000) \u2014 30 models for auth, influence, map, canvassing, email templates, landing pages, and tracking</li> <li>Drizzle ORM (Fastify Media API, port 4100) \u2014 3 models for video library, compilations, and job queue</li> </ul> <p>Both ORMs share the same PostgreSQL database but maintain separate schemas and migration workflows.</p>"},{"location":"v2/database/#database-architecture","title":"Database Architecture","text":"<p>Database: PostgreSQL 16 Connection: <code>DATABASE_URL</code> environment variable Total Models: 33 models organized into 9 groups Migration Tools: Prisma Migrate (main API), Drizzle Kit (media API)</p>"},{"location":"v2/database/#key-design-patterns","title":"Key Design Patterns","text":"<ol> <li>Audit Fields \u2014 Most models include:</li> <li><code>createdAt</code> / <code>updatedAt</code> timestamps</li> <li><code>createdByUserId</code> / <code>updatedByUserId</code> user references</li> <li> <p>Automatic tracking via Prisma middleware</p> </li> <li> <p>Soft Deletes \u2014 Some models use status fields instead of hard deletes:</p> </li> <li>User: <code>status</code> (ACTIVE/INACTIVE/SUSPENDED/EXPIRED)</li> <li>Campaign: <code>status</code> (DRAFT/ACTIVE/PAUSED/ARCHIVED)</li> <li> <p>Shift: <code>status</code> (OPEN/FULL/CANCELLED)</p> </li> <li> <p>JSON Fields \u2014 Used for flexible schema:</p> </li> <li><code>permissions</code> (User) \u2014 granular per-app permissions</li> <li><code>offices</code> (Representative) \u2014 array of office contact info</li> <li><code>tags</code> (videos) \u2014 array of tag strings</li> <li><code>geojson</code> (Cut) \u2014 GeoJSON polygon coordinates</li> <li> <p><code>blocks</code> (LandingPage) \u2014 GrapesJS editor output</p> </li> <li> <p>Enums \u2014 18 enums for type safety:</p> </li> <li> <p>UserRole, UserStatus, CampaignStatus, GovernmentLevel, EmailMethod, ResponseType, ResponseStatus, SupportLevel, GeocodeProvider, BuildingType, LocationHistoryAction, ShiftStatus, SignupStatus, SignupSource, CutCategory, VisitOutcome, CanvassSessionStatus, TrackPointEvent, EmailTemplateCategory, EditorMode, MkdocsExportMode</p> </li> <li> <p>Cascade Deletes \u2014 Foreign keys with <code>onDelete: Cascade</code>:</p> </li> <li>Deleting a Campaign deletes all CampaignEmail, RepresentativeResponse, CustomRecipient, Call records</li> <li>Deleting a Location deletes all Address and LocationHistory records</li> <li>Deleting a Shift deletes all ShiftSignup records</li> <li> <p>Deleting a CanvassSession deletes all CanvassVisit records</p> </li> <li> <p>Indexes \u2014 Strategic indexing for performance:</p> </li> <li>All foreign keys indexed (userId, campaignId, locationId, etc.)</li> <li>Composite indexes for common queries (latitude+longitude, locationId+unitNumber, etc.)</li> <li>Unique constraints (email, slug, postalCode, token, etc.)</li> </ol>"},{"location":"v2/database/#complete-entity-relationship-diagram","title":"Complete Entity Relationship Diagram","text":"<pre><code>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 }</code></pre>"},{"location":"v2/database/#model-groups","title":"Model Groups","text":"<p>The database is organized into 9 logical groups:</p>"},{"location":"v2/database/#1-auth-users","title":"1. Auth & Users","text":"<ul> <li>User \u2014 User accounts with roles (SUPER_ADMIN, INFLUENCE_ADMIN, MAP_ADMIN, USER, TEMP)</li> <li>RefreshToken \u2014 JWT refresh token storage with rotation</li> </ul> <p>Key Features: bcrypt passwords (12+ chars policy), role-based access control, temp user expiration, email verification</p>"},{"location":"v2/database/#2-influence","title":"2. Influence","text":"<ul> <li>Campaign \u2014 Advocacy campaigns with 12 feature flags</li> <li>Representative \u2014 Cached representative data from Represent API</li> <li>CampaignEmail \u2014 Email tracking (SMTP vs MAILTO)</li> <li>RepresentativeResponse \u2014 Response wall with moderation</li> <li>ResponseUpvote \u2014 Upvote tracking with IP + user uniqueness</li> <li>CustomRecipient \u2014 Custom email targets</li> <li>PostalCodeCache \u2014 Postal code geocoding cache</li> <li>EmailLog \u2014 Email audit trail</li> <li>EmailVerification \u2014 Verification token storage</li> <li>Call \u2014 Phone call tracking</li> </ul> <p>Key Features: Multi-government-level targeting, response moderation workflow (PENDING \u2192 APPROVED/REJECTED), BullMQ integration for email queue, upvote deduplication</p>"},{"location":"v2/database/#3-map-locations","title":"3. Map \u2014 Locations","text":"<ul> <li>Location \u2014 Building-level data with lat/lng, NAR integration</li> <li>Address \u2014 Unit-level data with support levels</li> <li>LocationHistory \u2014 Audit trail with 7 action types</li> <li>Shift \u2014 Volunteer shifts with cut relation</li> <li>ShiftSignup \u2014 Signup tracking</li> <li>Cut \u2014 GeoJSON polygon overlays</li> <li>MapSettings \u2014 Singleton for map center/zoom + walk sheet config</li> </ul> <p>Key Features: Building vs unit architecture, multi-provider geocoding (6 providers), NAR 2025 import support, spatial indexing, GeoJSON storage</p>"},{"location":"v2/database/#4-canvassing","title":"4. Canvassing","text":"<ul> <li>CanvassSession \u2014 Session lifecycle (ACTIVE \u2192 COMPLETED/ABANDONED)</li> <li>CanvassVisit \u2014 Visit recording with 7 outcome types</li> <li>TrackingSession \u2014 GPS tracking integration</li> <li>TrackPoint \u2014 GPS breadcrumb trail</li> </ul> <p>Key Features: Walking route algorithm, session abandonment logic (12h timeout), distance calculation, support level tracking</p>"},{"location":"v2/database/#5-email-templates","title":"5. Email Templates","text":"<ul> <li>EmailTemplate \u2014 Template master with categories</li> <li>EmailTemplateVariable \u2014 Variable definitions with validation</li> <li>EmailTemplateVersion \u2014 Version history</li> <li>EmailTemplateTestLog \u2014 Test email audit</li> </ul> <p>Key Features: Handlebars-style variable interpolation ({{VAR}}), conditional variables, system template protection, version auto-increment</p>"},{"location":"v2/database/#6-landing-pages","title":"6. Landing Pages","text":"<ul> <li>LandingPage \u2014 GrapesJS editor output with MkDocs export</li> <li>PageBlock \u2014 Reusable block library</li> </ul> <p>Key Features: GrapesJS JSON storage, MkDocs export modes (THEMED vs STANDALONE), SEO metadata, slug-based routing</p>"},{"location":"v2/database/#7-settings","title":"7. Settings","text":"<ul> <li>SiteSettings \u2014 Org branding + theme + SMTP + feature toggles</li> <li>MapSettings \u2014 Map center/zoom + walk sheet config</li> </ul> <p>Key Features: Singleton pattern, SMTP override hierarchy (SiteSettings \u2192 .env), feature flags</p>"},{"location":"v2/database/#8-media-drizzle-orm","title":"8. Media (Drizzle ORM)","text":"<ul> <li>videos \u2014 Video library with metadata, directory types, engagement stats</li> <li>compilations \u2014 Video compilation tracking</li> <li>jobs \u2014 Job queue with resource categories</li> </ul> <p>Key Features: Dual ORM architecture, FFprobe metadata extraction, directory type enum (9 types), job queue with GPU/CPU resource tracking</p>"},{"location":"v2/database/#9-sharedstandalone-models","title":"9. Shared/Standalone Models","text":"<ul> <li>Representative \u2014 Shared across campaigns</li> <li>PostalCodeCache \u2014 Shared geocoding cache</li> <li>EmailLog \u2014 Audit trail (no relations)</li> <li>EmailVerification \u2014 Standalone verification tokens</li> </ul>"},{"location":"v2/database/#field-types-reference","title":"Field Types Reference","text":"Prisma Type PostgreSQL Type Description Example <code>String</code> <code>text</code> Variable-length text <code>\"admin@cmlite.org\"</code> <code>String @db.Text</code> <code>text</code> Long-form text (no char limit) Campaign descriptions <code>Int</code> <code>integer</code> 32-bit integer <code>42</code> <code>BigInt</code> <code>bigint</code> 64-bit integer (Node: <code>number</code> mode) File sizes <code>Boolean</code> <code>boolean</code> True/false <code>true</code> <code>Decimal</code> <code>numeric</code> Arbitrary precision decimal Lat/lng coordinates <code>Decimal @db.Decimal(10, 8)</code> <code>numeric(10, 8)</code> 10 digits, 8 after decimal <code>53.54612345</code> <code>DateTime</code> <code>timestamp with time zone</code> Timestamp <code>2025-02-11T10:30:00Z</code> <code>DateTime @db.Date</code> <code>date</code> Date only (no time) Shift dates <code>Json</code> <code>jsonb</code> JSON data (binary storage) Arrays, objects <code>Enum</code> <code>enum</code> Enumerated type <code>UserRole.SUPER_ADMIN</code>"},{"location":"v2/database/#enum-definitions","title":"Enum Definitions","text":""},{"location":"v2/database/#auth-users","title":"Auth & Users","text":"<ul> <li>UserRole: <code>SUPER_ADMIN</code>, <code>INFLUENCE_ADMIN</code>, <code>MAP_ADMIN</code>, <code>USER</code>, <code>TEMP</code></li> <li>UserStatus: <code>ACTIVE</code>, <code>INACTIVE</code>, <code>SUSPENDED</code>, <code>EXPIRED</code></li> <li>UserCreatedVia: <code>ADMIN</code>, <code>PUBLIC_SHIFT_SIGNUP</code>, <code>STANDARD</code></li> </ul>"},{"location":"v2/database/#influence","title":"Influence","text":"<ul> <li>CampaignStatus: <code>DRAFT</code>, <code>ACTIVE</code>, <code>PAUSED</code>, <code>ARCHIVED</code></li> <li>GovernmentLevel: <code>FEDERAL</code>, <code>PROVINCIAL</code>, <code>MUNICIPAL</code>, <code>SCHOOL_BOARD</code></li> <li>EmailMethod: <code>SMTP</code>, <code>MAILTO</code></li> <li>CampaignEmailStatus: <code>QUEUED</code>, <code>SENT</code>, <code>FAILED</code>, <code>CLICKED</code>, <code>USER_INFO_CAPTURED</code></li> <li>ResponseType: <code>EMAIL</code>, <code>LETTER</code>, <code>PHONE_CALL</code>, <code>MEETING</code>, <code>SOCIAL_MEDIA</code>, <code>OTHER</code></li> <li>ResponseStatus: <code>PENDING</code>, <code>APPROVED</code>, <code>REJECTED</code></li> </ul>"},{"location":"v2/database/#map","title":"Map","text":"<ul> <li>SupportLevel: <code>LEVEL_1</code> (mapped to <code>\"1\"</code>), <code>LEVEL_2</code>, <code>LEVEL_3</code>, <code>LEVEL_4</code></li> <li>GeocodeProvider: <code>GOOGLE</code>, <code>MAPBOX</code>, <code>NOMINATIM</code>, <code>PHOTON</code>, <code>LOCATIONIQ</code>, <code>ARCGIS</code>, <code>UNKNOWN</code></li> <li>BuildingType: <code>SINGLE_FAMILY</code>, <code>MULTI_UNIT</code>, <code>MIXED_USE</code>, <code>COMMERCIAL</code></li> <li>LocationHistoryAction: <code>CREATED</code>, <code>UPDATED</code>, <code>GEOCODED</code>, <code>BULK_GEOCODED</code>, <code>MOVED_ON_MAP</code>, <code>IMPORTED_CSV</code>, <code>IMPORTED_NAR</code></li> <li>ShiftStatus: <code>OPEN</code>, <code>FULL</code>, <code>CANCELLED</code></li> <li>SignupStatus: <code>CONFIRMED</code>, <code>CANCELLED</code></li> <li>SignupSource: <code>AUTHENTICATED</code>, <code>PUBLIC</code>, <code>ADMIN</code></li> <li>CutCategory: <code>CUSTOM</code>, <code>WARD</code>, <code>NEIGHBORHOOD</code>, <code>DISTRICT</code></li> </ul>"},{"location":"v2/database/#canvassing","title":"Canvassing","text":"<ul> <li>VisitOutcome: <code>NOT_HOME</code>, <code>REFUSED</code>, <code>MOVED</code>, <code>ALREADY_VOTED</code>, <code>SPOKE_WITH</code>, <code>LEFT_LITERATURE</code>, <code>COME_BACK_LATER</code></li> <li>CanvassSessionStatus: <code>ACTIVE</code>, <code>COMPLETED</code>, <code>ABANDONED</code></li> <li>TrackPointEvent: <code>LOCATION_ADDED</code>, <code>VISIT_RECORDED</code>, <code>SESSION_STARTED</code>, <code>SESSION_ENDED</code></li> </ul>"},{"location":"v2/database/#email-templates","title":"Email Templates","text":"<ul> <li>EmailTemplateCategory: <code>INFLUENCE</code>, <code>MAP</code>, <code>SYSTEM</code></li> </ul>"},{"location":"v2/database/#landing-pages","title":"Landing Pages","text":"<ul> <li>EditorMode: <code>VISUAL</code>, <code>CODE</code></li> <li>MkdocsExportMode: <code>THEMED</code>, <code>STANDALONE</code></li> </ul>"},{"location":"v2/database/#media-drizzle","title":"Media (Drizzle)","text":"<ul> <li>DirectoryType (TypeScript literal): <code>'studios'</code>, <code>'gifs'</code>, <code>'private'</code>, <code>'inbox'</code>, <code>'curated'</code>, <code>'playback'</code>, <code>'compilations'</code>, <code>'videos'</code>, <code>'highlights'</code></li> <li>ResourceCategory (TypeScript literal): <code>'gpu_ai'</code>, <code>'gpu_encode'</code>, <code>'cpu'</code></li> <li>JobStatus (TypeScript literal): <code>'pending'</code>, <code>'queued'</code>, <code>'running'</code>, <code>'completed'</code>, <code>'failed'</code>, <code>'cancelled'</code></li> </ul>"},{"location":"v2/database/#index-strategy-overview","title":"Index Strategy Overview","text":""},{"location":"v2/database/#foreign-key-indexes","title":"Foreign Key Indexes","text":"<p>All foreign key fields are indexed for join performance: - <code>userId</code>, <code>campaignId</code>, <code>locationId</code>, <code>addressId</code>, <code>shiftId</code>, <code>cutId</code>, <code>sessionId</code>, <code>templateId</code>, <code>trackingSessionId</code></p>"},{"location":"v2/database/#composite-indexes","title":"Composite Indexes","text":"<p>Strategic multi-column indexes for common query patterns: - <code>[latitude, longitude]</code> (Location) \u2014 spatial queries - <code>[locationId, unitNumber]</code> (Address) \u2014 unit lookups - <code>[campaignId, status]</code> (RepresentativeResponse) \u2014 filtered response lists - <code>[isActive, lastRecordedAt]</code> (TrackingSession) \u2014 active session cleanup - <code>[templateId, createdAt(sort: Desc)]</code> (EmailTemplateVersion) \u2014 version history - <code>[directoryType, isValid, orientation]</code> (videos) \u2014 media library filtering</p>"},{"location":"v2/database/#unique-constraints","title":"Unique Constraints","text":"<p>Enforce data integrity: - <code>email</code> (User) - <code>slug</code> (Campaign, LandingPage) - <code>postalCode</code> (PostalCodeCache) - <code>token</code> (RefreshToken, EmailVerification) - <code>key</code> (EmailTemplate) - <code>[responseId, userId]</code> (ResponseUpvote) \u2014 prevent duplicate upvotes from logged-in users - <code>[responseId, upvotedIp]</code> (ResponseUpvote) \u2014 prevent duplicate upvotes from same IP - <code>[shiftId, userEmail]</code> (ShiftSignup) \u2014 prevent duplicate shift signups - <code>[templateId, key]</code> (EmailTemplateVariable) \u2014 unique variable keys per template - <code>[templateId, versionNumber]</code> (EmailTemplateVersion) \u2014 sequential version numbers</p>"},{"location":"v2/database/#foreign-key-conventions","title":"Foreign Key Conventions","text":""},{"location":"v2/database/#cascade-deletes","title":"Cascade Deletes","text":"<p><pre><code>onDelete: Cascade\n</code></pre> 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</p>"},{"location":"v2/database/#set-null","title":"Set Null","text":"<p><pre><code>onDelete: SetNull\n</code></pre> 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</p>"},{"location":"v2/database/#related-documentation","title":"Related Documentation","text":"<ul> <li>Schema Reference \u2014 Complete table and field listing</li> <li>Migration Workflow \u2014 Prisma and Drizzle migration processes</li> <li>Seeding \u2014 Default data and seed script</li> <li>Indexes \u2014 Detailed index strategy and performance notes</li> <li>Auth Models \u2014 User and authentication tables</li> <li>Influence Models \u2014 Campaign and advocacy tables</li> <li>Map Models \u2014 Location, shift, and cut tables</li> <li>Canvassing Models \u2014 Session and visit tracking</li> <li>Email Template Models \u2014 Template system</li> <li>Landing Page Models \u2014 Page builder and blocks</li> <li>Settings Models \u2014 Site and map settings</li> <li>Media Models \u2014 Video library (Drizzle ORM)</li> </ul>"},{"location":"v2/database/#quick-links","title":"Quick Links","text":"<ul> <li>Prisma Schema File</li> <li>Drizzle Schema File</li> <li>API Documentation</li> <li>Admin GUI Documentation</li> </ul>"},{"location":"v2/database/indexes/","title":"Index Strategy & Performance","text":""},{"location":"v2/database/indexes/#overview","title":"Overview","text":"<p>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.</p> <p>Total Indexes: 60+ (Prisma: 50+, Drizzle: 10+)</p> <p>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</p>"},{"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":"<ul> <li>Unique: <code>email</code> \u2014 Login lookups (<code>WHERE email = ?</code>)</li> </ul>"},{"location":"v2/database/indexes/#refreshtoken","title":"RefreshToken","text":"<ul> <li>Unique: <code>token</code> \u2014 Refresh endpoint lookups (<code>WHERE token = ?</code>)</li> <li>Foreign Key: <code>userId</code> \u2014 User deletion cascades</li> </ul>"},{"location":"v2/database/indexes/#influence","title":"Influence","text":""},{"location":"v2/database/indexes/#campaign","title":"Campaign","text":"<ul> <li>Unique: <code>slug</code> \u2014 Public campaign page lookups (<code>WHERE slug = ?</code>)</li> </ul>"},{"location":"v2/database/indexes/#representative","title":"Representative","text":"<ul> <li>Non-unique: <code>postalCode</code> \u2014 Postal code lookups (<code>WHERE postalCode = ?</code>)</li> </ul>"},{"location":"v2/database/indexes/#campaignemail","title":"CampaignEmail","text":"<ul> <li>Foreign Key: <code>campaignId</code> \u2014 Campaign email stats (<code>JOIN campaign_emails ON campaign_id = ?</code>)</li> <li>Non-unique: <code>campaignSlug</code> \u2014 Slug-based queries</li> </ul>"},{"location":"v2/database/indexes/#representativeresponse","title":"RepresentativeResponse","text":"<ul> <li>Foreign Key: <code>campaignId</code> \u2014 Campaign response wall (<code>JOIN representative_responses ON campaign_id = ?</code>)</li> <li>Non-unique: <code>campaignSlug</code> \u2014 Slug-based queries</li> </ul>"},{"location":"v2/database/indexes/#responseupvote","title":"ResponseUpvote","text":"<ul> <li>Unique: <code>[responseId, userId]</code> \u2014 Prevent duplicate upvotes from logged-in users</li> <li>Unique: <code>[responseId, upvotedIp]</code> \u2014 Prevent duplicate upvotes from same IP</li> </ul>"},{"location":"v2/database/indexes/#customrecipient","title":"CustomRecipient","text":"<ul> <li>Foreign Key: <code>campaignId</code> \u2014 Campaign custom recipients (<code>JOIN custom_recipients ON campaign_id = ?</code>)</li> </ul>"},{"location":"v2/database/indexes/#postalcodecache","title":"PostalCodeCache","text":"<ul> <li>Unique: <code>postalCode</code> \u2014 Postal code cache lookups (<code>WHERE postal_code = ?</code>)</li> </ul>"},{"location":"v2/database/indexes/#call","title":"Call","text":"<ul> <li>Foreign Key: <code>campaignId</code> \u2014 Campaign call tracking (<code>JOIN calls ON campaign_id = ?</code>)</li> </ul>"},{"location":"v2/database/indexes/#map-locations","title":"Map \u2014 Locations","text":""},{"location":"v2/database/indexes/#location","title":"Location","text":"<ul> <li>Unique: <code>locGuid</code> \u2014 NAR location GUID lookups</li> <li>Composite: <code>[latitude, longitude]</code> \u2014 Spatial queries (nearby locations, bounding box searches)</li> <li>Non-unique: <code>postalCode</code> \u2014 Postal code filtering</li> </ul> <p>Query Optimization: <pre><code>-- Uses composite index for bounding box queries\nSELECT * FROM locations\nWHERE latitude BETWEEN ? AND ?\n AND longitude BETWEEN ? AND ?;\n</code></pre></p>"},{"location":"v2/database/indexes/#address","title":"Address","text":"<ul> <li>Unique: <code>addrGuid</code> \u2014 NAR address GUID lookups</li> <li>Foreign Key: <code>locationId</code> \u2014 Location addresses (<code>JOIN addresses ON location_id = ?</code>)</li> <li>Composite: <code>[locationId, unitNumber]</code> \u2014 Unit lookups within building</li> </ul> <p>Query Optimization: <pre><code>-- Uses composite index for unit-specific queries\nSELECT * FROM addresses\nWHERE location_id = ? AND unit_number = ?;\n</code></pre></p>"},{"location":"v2/database/indexes/#locationhistory","title":"LocationHistory","text":"<ul> <li>Foreign Key: <code>locationId</code> \u2014 Location history (<code>JOIN location_history ON location_id = ?</code>)</li> <li>Foreign Key: <code>userId</code> \u2014 User edit history (<code>JOIN location_history ON user_id = ?</code>)</li> <li>Non-unique: <code>createdAt</code> \u2014 Temporal queries (recent edits, audit trails)</li> </ul>"},{"location":"v2/database/indexes/#map-shifts-cuts","title":"Map \u2014 Shifts & Cuts","text":""},{"location":"v2/database/indexes/#shift","title":"Shift","text":"<ul> <li>Foreign Key: <code>cutId</code> \u2014 Cut shifts (<code>JOIN shifts ON cut_id = ?</code>)</li> </ul>"},{"location":"v2/database/indexes/#shiftsignup","title":"ShiftSignup","text":"<ul> <li>Unique: <code>[shiftId, userEmail]</code> \u2014 Prevent duplicate shift signups</li> <li>Foreign Key: <code>shiftId</code> \u2014 Shift signups (<code>JOIN shift_signups ON shift_id = ?</code>)</li> </ul>"},{"location":"v2/database/indexes/#canvassing","title":"Canvassing","text":""},{"location":"v2/database/indexes/#canvasssession","title":"CanvassSession","text":"<ul> <li>Foreign Key: <code>userId</code> \u2014 User canvass sessions (<code>JOIN canvass_sessions ON user_id = ?</code>)</li> <li>Foreign Key: <code>cutId</code> \u2014 Cut canvass sessions (<code>JOIN canvass_sessions ON cut_id = ?</code>)</li> <li>Foreign Key: <code>shiftId</code> \u2014 Shift canvass sessions (<code>JOIN canvass_sessions ON shift_id = ?</code>)</li> </ul>"},{"location":"v2/database/indexes/#canvassvisit","title":"CanvassVisit","text":"<ul> <li>Foreign Key: <code>addressId</code> \u2014 Address visit history (<code>JOIN canvass_visits ON address_id = ?</code>)</li> <li>Foreign Key: <code>userId</code> \u2014 User visit history (<code>JOIN canvass_visits ON user_id = ?</code>)</li> <li>Foreign Key: <code>shiftId</code> \u2014 Shift visits (<code>JOIN canvass_visits ON shift_id = ?</code>)</li> <li>Foreign Key: <code>sessionId</code> \u2014 Session visits (<code>JOIN canvass_visits ON session_id = ?</code>)</li> <li>Non-unique: <code>visitedAt</code> \u2014 Temporal queries (recent visits, activity feeds)</li> </ul>"},{"location":"v2/database/indexes/#trackingsession","title":"TrackingSession","text":"<ul> <li>Unique: <code>canvassSessionId</code> \u2014 One-to-one relationship with CanvassSession</li> <li>Foreign Key: <code>userId</code> \u2014 User GPS sessions (<code>JOIN tracking_sessions ON user_id = ?</code>)</li> <li>Non-unique: <code>isActive</code> \u2014 Active session filtering (<code>WHERE is_active = true</code>)</li> <li>Composite: <code>[isActive, lastRecordedAt]</code> \u2014 Session cleanup queries (abandoned sessions)</li> </ul> <p>Query Optimization: <pre><code>-- 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</code></pre></p>"},{"location":"v2/database/indexes/#trackpoint","title":"TrackPoint","text":"<ul> <li>Composite: <code>[trackingSessionId, recordedAt]</code> \u2014 Temporal GPS queries (session breadcrumb trail)</li> <li>Non-unique: <code>recordedAt</code> \u2014 Cross-session temporal queries</li> </ul>"},{"location":"v2/database/indexes/#email-templates","title":"Email Templates","text":""},{"location":"v2/database/indexes/#emailtemplate","title":"EmailTemplate","text":"<ul> <li>Unique: <code>key</code> \u2014 Template key lookups (<code>WHERE key = 'campaign-email'</code>)</li> <li>Non-unique: <code>category</code> \u2014 Category filtering (<code>WHERE category = 'INFLUENCE'</code>)</li> <li>Non-unique: <code>isActive</code> \u2014 Active template filtering (<code>WHERE is_active = true</code>)</li> </ul>"},{"location":"v2/database/indexes/#emailtemplatevariable","title":"EmailTemplateVariable","text":"<ul> <li>Unique: <code>[templateId, key]</code> \u2014 Unique variable keys per template</li> <li>Foreign Key: <code>templateId</code> \u2014 Template variables (<code>JOIN email_template_variables ON template_id = ?</code>)</li> </ul>"},{"location":"v2/database/indexes/#emailtemplateversion","title":"EmailTemplateVersion","text":"<ul> <li>Unique: <code>[templateId, versionNumber]</code> \u2014 Sequential version numbers per template</li> <li>Composite: <code>[templateId, createdAt(sort: Desc)]</code> \u2014 Recent version history</li> </ul> <p>Query Optimization: <pre><code>-- Uses composite index for recent version queries\nSELECT * FROM email_template_versions\nWHERE template_id = ?\nORDER BY created_at DESC\nLIMIT 10;\n</code></pre></p>"},{"location":"v2/database/indexes/#emailtemplatetestlog","title":"EmailTemplateTestLog","text":"<ul> <li>Composite: <code>[templateId, sentAt(sort: Desc)]</code> \u2014 Recent test logs</li> </ul>"},{"location":"v2/database/indexes/#landing-pages","title":"Landing Pages","text":""},{"location":"v2/database/indexes/#landingpage","title":"LandingPage","text":"<ul> <li>Unique: <code>slug</code> \u2014 Public page lookups (<code>WHERE slug = 'about'</code>)</li> </ul>"},{"location":"v2/database/indexes/#media-drizzle-orm","title":"Media (Drizzle ORM)","text":""},{"location":"v2/database/indexes/#videos","title":"videos","text":"<ul> <li>Unique: <code>path</code> \u2014 File path lookups (<code>WHERE path = '/media/local/videos/file.mp4'</code>)</li> <li>Non-unique: <code>orientation</code> \u2014 Orientation filtering (<code>WHERE orientation = 'landscape'</code>)</li> <li>Non-unique: <code>producer</code> \u2014 Producer filtering (<code>WHERE producer = 'Studio A'</code>)</li> <li>Non-unique: <code>isValid</code> \u2014 Valid video filtering (<code>WHERE is_valid = true</code>)</li> <li>Non-unique: <code>directoryType</code> \u2014 Directory type filtering (<code>WHERE directory_type = 'studios'</code>)</li> <li>Composite: <code>[durationSeconds, fileSize, width, height]</code> \u2014 Fingerprint matching (duplicate detection)</li> <li>Composite: <code>[directoryType, isValid, orientation]</code> \u2014 Common filtering pattern</li> </ul> <p>Query Optimization: <pre><code>-- 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</code></pre></p>"},{"location":"v2/database/indexes/#jobs","title":"jobs","text":"<ul> <li>Composite: <code>[status, priority, createdAt]</code> \u2014 Job queue processing</li> <li>Composite: <code>[resourceCategory, status]</code> \u2014 Resource-based filtering</li> <li>Non-unique: <code>pipelineId</code> \u2014 Pipeline job filtering</li> </ul> <p>Query Optimization: <pre><code>-- Uses composite index for job queue queries\nSELECT * FROM jobs\nWHERE status = 'pending'\nORDER BY priority ASC, created_at ASC\nLIMIT 10;\n</code></pre></p>"},{"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":"<pre><code>// \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</code></pre>"},{"location":"v2/database/indexes/#2-use-composite-indexes-for-multi-column-filters","title":"2. Use Composite Indexes for Multi-Column Filters","text":"<pre><code>// \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</code></pre>"},{"location":"v2/database/indexes/#3-use-foreign-key-indexes-for-joins","title":"3. Use Foreign Key Indexes for JOINs","text":"<pre><code>// \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</code></pre>"},{"location":"v2/database/indexes/#4-use-unique-indexes-for-deduplication","title":"4. Use Unique Indexes for Deduplication","text":"<pre><code>// \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</code></pre>"},{"location":"v2/database/indexes/#5-use-temporal-indexes-for-date-filtering","title":"5. Use Temporal Indexes for Date Filtering","text":"<pre><code>// \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</code></pre>"},{"location":"v2/database/indexes/#index-selectivity","title":"Index Selectivity","text":"<p>Selectivity = Percentage of unique values in indexed column. Higher selectivity = better index performance.</p>"},{"location":"v2/database/indexes/#high-selectivity-good","title":"High Selectivity (Good)","text":"<ul> <li>email (User) \u2014 100% unique (1 user per email)</li> <li>token (RefreshToken) \u2014 100% unique (1 token per record)</li> <li>slug (Campaign, LandingPage) \u2014 100% unique (1 record per slug)</li> <li>[responseId, userId] (ResponseUpvote) \u2014 High uniqueness (1 upvote per user per response)</li> </ul>"},{"location":"v2/database/indexes/#medium-selectivity-okay","title":"Medium Selectivity (Okay)","text":"<ul> <li>postalCode (Location) \u2014 ~50% unique (multiple locations per postal code)</li> <li>campaignId (CampaignEmail) \u2014 ~10% unique (100s of emails per campaign)</li> <li>directoryType (videos) \u2014 ~11% unique (9 directory types)</li> </ul>"},{"location":"v2/database/indexes/#low-selectivity-poor-for-filtering-good-for-covering-index","title":"Low Selectivity (Poor for filtering, good for covering index)","text":"<ul> <li>isActive (TrackingSession) \u2014 ~50% unique (active vs inactive)</li> <li>status (Campaign) \u2014 ~25% unique (4 statuses: DRAFT, ACTIVE, PAUSED, ARCHIVED)</li> <li>role (User) \u2014 ~20% unique (5 roles)</li> </ul> <p>Optimization: - Use low-selectivity indexes as first column in composite index only - Example: <code>[isActive, lastRecordedAt]</code> uses <code>isActive</code> to narrow search, then <code>lastRecordedAt</code> for ordering</p>"},{"location":"v2/database/indexes/#index-maintenance","title":"Index Maintenance","text":""},{"location":"v2/database/indexes/#prisma-indexes-automatic","title":"Prisma Indexes (Automatic)","text":"<p>Prisma migrations automatically create indexes defined in <code>schema.prisma</code>: <pre><code>model Location {\n latitude Decimal\n longitude Decimal\n\n @@index([latitude, longitude]) // Composite index\n}\n</code></pre></p>"},{"location":"v2/database/indexes/#drizzle-indexes-manual-in-schema","title":"Drizzle Indexes (Manual in Schema)","text":"<p>Drizzle indexes defined in <code>schema.ts</code>: <pre><code>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</code></pre></p>"},{"location":"v2/database/indexes/#index-size-monitoring","title":"Index Size Monitoring","text":"<pre><code>-- 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</code></pre>"},{"location":"v2/database/indexes/#unused-index-detection","title":"Unused Index Detection","text":"<pre><code>-- 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</code></pre>"},{"location":"v2/database/indexes/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/database/indexes/#index-trade-offs","title":"Index Trade-offs","text":"<ul> <li>Pros: Faster SELECT queries, enforces uniqueness, prevents N+1</li> <li>Cons: Slower INSERT/UPDATE/DELETE (index must be updated), increased storage</li> </ul> <p>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)</p>"},{"location":"v2/database/indexes/#query-planning","title":"Query Planning","text":"<p>Use <code>EXPLAIN ANALYZE</code> to verify index usage: <pre><code>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</code></pre></p>"},{"location":"v2/database/indexes/#index-bloat","title":"Index Bloat","text":"<p>Over time, indexes can become bloated (unused space). Monitor with: <pre><code>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</code></pre></p> <p>Fix bloat: <code>REINDEX INDEX index_name;</code> (requires table lock)</p>"},{"location":"v2/database/indexes/#common-performance-issues","title":"Common Performance Issues","text":""},{"location":"v2/database/indexes/#issue-slow-campaign-email-stats-query","title":"Issue: Slow campaign email stats query","text":"<p>Query: <pre><code>SELECT COUNT(*) FROM campaign_emails WHERE campaign_id = ?;\n</code></pre></p> <p>Solution: Already optimized (uses <code>campaignId</code> foreign key index)</p>"},{"location":"v2/database/indexes/#issue-slow-location-bounding-box-queries","title":"Issue: Slow location bounding box queries","text":"<p>Query: <pre><code>SELECT * FROM locations WHERE latitude > ? AND latitude < ? AND longitude > ? AND longitude < ?;\n</code></pre></p> <p>Solution: Already optimized (uses <code>[latitude, longitude]</code> composite index)</p>"},{"location":"v2/database/indexes/#issue-slow-active-session-cleanup","title":"Issue: Slow active session cleanup","text":"<p>Query: <pre><code>SELECT * FROM tracking_sessions WHERE is_active = true AND last_recorded_at < ?;\n</code></pre></p> <p>Solution: Already optimized (uses <code>[isActive, lastRecordedAt]</code> composite index)</p>"},{"location":"v2/database/indexes/#issue-slow-template-version-history","title":"Issue: Slow template version history","text":"<p>Query: <pre><code>SELECT * FROM email_template_versions WHERE template_id = ? ORDER BY created_at DESC LIMIT 10;\n</code></pre></p> <p>Solution: Already optimized (uses <code>[templateId, createdAt(sort: Desc)]</code> composite index)</p>"},{"location":"v2/database/indexes/#related-documentation","title":"Related Documentation","text":"<ul> <li>Database Overview \u2014 Complete ER diagram</li> <li>Schema Reference \u2014 All model fields</li> <li>Migration Workflow \u2014 Creating indexes in migrations</li> <li>Common Queries \u2014 Query examples with index usage</li> <li>PostgreSQL Index Documentation</li> </ul>"},{"location":"v2/database/migrations/","title":"Migration Workflow","text":""},{"location":"v2/database/migrations/#overview","title":"Overview","text":"<p>Changemaker Lite V2 uses a dual ORM architecture with separate migration workflows:</p> <ul> <li>Prisma Migrate \u2014 Main API (Express, 30 models)</li> <li>Drizzle Kit \u2014 Media API (Fastify, 3 models)</li> </ul> <p>Both ORMs share the same PostgreSQL database but maintain independent migration histories.</p>"},{"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":"<p>Edit <code>api/prisma/schema.prisma</code>: <pre><code>model Location {\n id String @id @default(cuid())\n address String\n // Add new field:\n province String?\n // ...\n}\n</code></pre></p>"},{"location":"v2/database/migrations/#2-create-migration","title":"2. Create Migration","text":"<pre><code>cd api\nnpx prisma migrate dev --name add_province_to_location\n</code></pre> <p>This command: - Generates SQL migration file in <code>prisma/migrations/</code> - Applies migration to database - Regenerates Prisma Client - Updates <code>_prisma_migrations</code> table</p> <p>Output: <pre><code>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</code></pre></p>"},{"location":"v2/database/migrations/#3-review-migration-sql","title":"3. Review Migration SQL","text":"<pre><code>-- migrations/20260213120000_add_province_to_location/migration.sql\n-- AlterTable\nALTER TABLE \"locations\" ADD COLUMN \"province\" TEXT;\n</code></pre>"},{"location":"v2/database/migrations/#4-commit-migration","title":"4. Commit Migration","text":"<pre><code>git add prisma/migrations/\ngit commit -m \"Add province field to Location model\"\n</code></pre>"},{"location":"v2/database/migrations/#production-workflow","title":"Production Workflow","text":""},{"location":"v2/database/migrations/#1-deploy-migration","title":"1. Deploy Migration","text":"<pre><code>docker compose exec api npx prisma migrate deploy\n</code></pre> <p>This command: - Applies pending migrations from <code>prisma/migrations/</code> - Does NOT create new migrations - Does NOT prompt for confirmations - Safe for production/CI pipelines</p>"},{"location":"v2/database/migrations/#2-verify-migration-status","title":"2. Verify Migration Status","text":"<pre><code>docker compose exec api npx prisma migrate status\n</code></pre> <p>Output: <pre><code>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</code></pre></p>"},{"location":"v2/database/migrations/#common-migration-scenarios","title":"Common Migration Scenarios","text":""},{"location":"v2/database/migrations/#add-field-nullable","title":"Add Field (Nullable)","text":"<p><pre><code>model Location {\n federalDistrict String? // Add nullable field\n}\n</code></pre> Migration: <pre><code>ALTER TABLE \"locations\" ADD COLUMN \"federal_district\" TEXT;\n</code></pre></p>"},{"location":"v2/database/migrations/#add-field-required-with-default","title":"Add Field (Required with Default)","text":"<p><pre><code>model Location {\n buildingType BuildingType @default(SINGLE_FAMILY)\n}\n</code></pre> Migration: <pre><code>ALTER TABLE \"locations\" ADD COLUMN \"building_type\" TEXT NOT NULL DEFAULT 'SINGLE_FAMILY';\n</code></pre></p>"},{"location":"v2/database/migrations/#add-relation","title":"Add Relation","text":"<p><pre><code>model Shift {\n cutId String?\n cut Cut? @relation(fields: [cutId], references: [id], onDelete: SetNull)\n}\n</code></pre> Migration: <pre><code>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</code></pre></p>"},{"location":"v2/database/migrations/#change-field-type","title":"Change Field Type","text":"<p><pre><code>model Location {\n geocodeConfidence Int? // Changed from String? to Int?\n}\n</code></pre> Migration (requires data migration): <pre><code>-- 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</code></pre></p>"},{"location":"v2/database/migrations/#add-enum","title":"Add Enum","text":"<p><pre><code>enum BuildingType {\n SINGLE_FAMILY\n MULTI_UNIT\n MIXED_USE\n COMMERCIAL\n}\n</code></pre> Migration: <pre><code>CREATE TYPE \"BuildingType\" AS ENUM ('SINGLE_FAMILY', 'MULTI_UNIT', 'MIXED_USE', 'COMMERCIAL');\n</code></pre></p>"},{"location":"v2/database/migrations/#add-index","title":"Add Index","text":"<p><pre><code>model Location {\n latitude Decimal\n longitude Decimal\n\n @@index([latitude, longitude])\n}\n</code></pre> Migration: <pre><code>CREATE INDEX \"locations_latitude_longitude_idx\" ON \"locations\"(\"latitude\", \"longitude\");\n</code></pre></p>"},{"location":"v2/database/migrations/#migration-commands-reference","title":"Migration Commands Reference","text":"Command Description Environment <code>npx prisma migrate dev</code> Create + apply migration Development <code>npx prisma migrate deploy</code> Apply pending migrations Production/CI <code>npx prisma migrate status</code> Check migration status All <code>npx prisma migrate reset</code> Reset DB + apply all migrations Development only <code>npx prisma db push</code> Push schema without migrations Prototyping only <code>npx prisma studio</code> 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":"<ul> <li>Always review generated SQL before committing</li> <li>Test migrations on dev database first</li> <li>Back up production database before deploying migrations</li> <li>Use nullable fields for new columns on existing tables</li> <li>Use <code>@default()</code> for new required fields</li> <li>Commit migration files to version control</li> </ul>"},{"location":"v2/database/migrations/#dont","title":"\u274c DON'T","text":"<ul> <li>Use <code>prisma db push</code> in production (skips migrations)</li> <li>Use <code>prisma migrate reset</code> in production (deletes data)</li> <li>Manually edit migration files after applying</li> <li>Delete old migration files (breaks history)</li> <li>Change field names without data migration plan</li> </ul>"},{"location":"v2/database/migrations/#drizzle-migration-workflow","title":"Drizzle Migration Workflow","text":""},{"location":"v2/database/migrations/#development-workflow_1","title":"Development Workflow","text":""},{"location":"v2/database/migrations/#1-modify-schema_1","title":"1. Modify Schema","text":"<p>Edit <code>api/src/modules/media/db/schema.ts</code>: <pre><code>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</code></pre></p>"},{"location":"v2/database/migrations/#2-push-schema-changes","title":"2. Push Schema Changes","text":"<pre><code>cd api\nnpx drizzle-kit push\n</code></pre> <p>This command: - Generates SQL diff from schema - Applies changes directly to database - Does NOT create migration files (Drizzle push mode) - Updates database schema immediately</p> <p>Output: <pre><code>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</code></pre></p>"},{"location":"v2/database/migrations/#3-verify-schema","title":"3. Verify Schema","text":"<p><pre><code>npx drizzle-kit studio\n</code></pre> Opens Drizzle Studio at <code>https://local.drizzle.studio/</code> for database inspection.</p>"},{"location":"v2/database/migrations/#production-workflow_1","title":"Production Workflow","text":"<p>Same as development: <pre><code>docker compose exec media-api npx drizzle-kit push\n</code></pre></p>"},{"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 <code>_prisma_migrations</code> \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) <p>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)</p>"},{"location":"v2/database/migrations/#drizzle-commands-reference","title":"Drizzle Commands Reference","text":"Command Description <code>npx drizzle-kit push</code> Push schema changes to DB <code>npx drizzle-kit studio</code> Open Drizzle Studio <code>npx drizzle-kit generate</code> 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":"<pre><code>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</code></pre> <p>File naming: <code>YYYYMMDDHHMMSS_description/migration.sql</code></p> <p>migration_lock.toml: <pre><code># Please do not edit this file manually\nprovider = \"postgresql\"\n</code></pre></p>"},{"location":"v2/database/migrations/#drizzle-schema-no-migrations","title":"Drizzle Schema (No Migrations)","text":"<pre><code>api/src/modules/media/db/\n\u251c\u2500\u2500 schema.ts # Source of truth\n\u2514\u2500\u2500 drizzle.config.ts # Drizzle config\n</code></pre>"},{"location":"v2/database/migrations/#rollback-strategies","title":"Rollback Strategies","text":""},{"location":"v2/database/migrations/#prisma-rollback-manual","title":"Prisma Rollback (Manual)","text":"<p>Scenario: Migration <code>20260213120000_add_province</code> caused issues.</p> <p>Step 1: Identify last good migration <pre><code>npx prisma migrate status\n</code></pre></p> <p>Step 2: Manually revert migration SQL <pre><code>-- Reverse of migration.sql\nALTER TABLE \"locations\" DROP COLUMN \"province\";\n</code></pre></p> <p>Step 3: Mark migration as rolled back <pre><code>DELETE FROM \"_prisma_migrations\" WHERE migration_name = '20260213120000_add_province';\n</code></pre></p> <p>Step 4: Remove migration file <pre><code>rm -rf prisma/migrations/20260213120000_add_province/\n</code></pre></p> <p>Step 5: Fix schema Edit <code>prisma/schema.prisma</code> to remove <code>province</code> field.</p> <p>Step 6: Create new migration <pre><code>npx prisma migrate dev --name remove_province_from_location\n</code></pre></p>"},{"location":"v2/database/migrations/#drizzle-rollback-manual","title":"Drizzle Rollback (Manual)","text":"<p>Step 1: Revert schema changes in <code>schema.ts</code></p> <p>Step 2: Push reverted schema <pre><code>npx drizzle-kit push\n</code></pre></p> <p>Step 3: If data loss occurred, restore from backup</p>"},{"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":"<p>Cause: Database state doesn't match expected state Solution: <pre><code>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</code></pre></p>"},{"location":"v2/database/migrations/#error-unique-constraint-violation","title":"Error: \"Unique constraint violation\"","text":"<p>Cause: Trying to add unique constraint on column with duplicate values Solution: 1. Clean up duplicate data first 2. Run migration</p>"},{"location":"v2/database/migrations/#error-column-cannot-be-not-null","title":"Error: \"Column cannot be NOT NULL\"","text":"<p>Cause: Trying to add required field to table with existing rows Solution: Use <code>@default()</code> or make field nullable</p>"},{"location":"v2/database/migrations/#error-foreign-key-constraint-failed","title":"Error: \"Foreign key constraint failed\"","text":"<p>Cause: Referencing non-existent records Solution: Ensure related records exist before adding FK</p>"},{"location":"v2/database/migrations/#database-backup-before-migration","title":"Database Backup Before Migration","text":""},{"location":"v2/database/migrations/#development","title":"Development","text":"<pre><code>docker compose exec v2-postgres pg_dump -U changemaker_v2 changemaker_v2 > backup.sql\n</code></pre>"},{"location":"v2/database/migrations/#production","title":"Production","text":"<pre><code># 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</code></pre>"},{"location":"v2/database/migrations/#restore-from-backup","title":"Restore from Backup","text":"<pre><code># 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</code></pre>"},{"location":"v2/database/migrations/#cicd-integration","title":"CI/CD Integration","text":""},{"location":"v2/database/migrations/#github-actions-example","title":"GitHub Actions Example","text":"<pre><code>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</code></pre>"},{"location":"v2/database/migrations/#related-documentation","title":"Related Documentation","text":"<ul> <li>Database Overview \u2014 Architecture and models</li> <li>Schema Reference \u2014 All model fields</li> <li>Seeding \u2014 Default data</li> <li>Prisma Documentation</li> <li>Drizzle Documentation</li> </ul>"},{"location":"v2/database/schema/","title":"Complete Schema Reference","text":"<p>This page provides a comprehensive listing of all 33 models across both Prisma and Drizzle ORMs.</p>"},{"location":"v2/database/schema/#models-summary","title":"Models Summary","text":"Group Model Table Name Description ORM Auth & Users User <code>users</code> User accounts with role-based access control Prisma RefreshToken <code>refresh_tokens</code> JWT refresh token storage Prisma Influence Campaign <code>campaigns</code> Advocacy campaigns with feature flags Prisma Representative <code>representatives</code> Cached representative data from Represent API Prisma CampaignEmail <code>campaign_emails</code> Email tracking and delivery logs Prisma RepresentativeResponse <code>representative_responses</code> Response wall submissions with moderation Prisma ResponseUpvote <code>response_upvotes</code> Upvote tracking with deduplication Prisma CustomRecipient <code>custom_recipients</code> Custom email targets for campaigns Prisma PostalCodeCache <code>postal_code_cache</code> Postal code geocoding cache Prisma EmailLog <code>email_logs</code> Global email audit trail Prisma EmailVerification <code>email_verifications</code> Email verification tokens Prisma Call <code>calls</code> Phone call tracking Prisma Map \u2014 Locations Location <code>locations</code> Building-level address data with geocoding Prisma Address <code>addresses</code> Unit-level data with support levels Prisma LocationHistory <code>location_history</code> Audit trail for location changes Prisma Map \u2014 Shifts & Cuts Shift <code>shifts</code> Volunteer shifts with scheduling Prisma ShiftSignup <code>shift_signups</code> Shift signup tracking Prisma Cut <code>cuts</code> GeoJSON polygon overlays for map filtering Prisma MapSettings <code>map_settings</code> Singleton for map configuration Prisma Canvassing CanvassSession <code>canvass_sessions</code> Canvassing session lifecycle Prisma CanvassVisit <code>canvass_visits</code> Visit recording with outcomes Prisma TrackingSession <code>tracking_sessions</code> GPS tracking sessions Prisma TrackPoint <code>track_points</code> GPS breadcrumb trail Prisma Email Templates EmailTemplate <code>email_templates</code> Email template master records Prisma EmailTemplateVariable <code>email_template_variables</code> Template variable definitions Prisma EmailTemplateVersion <code>email_template_versions</code> Template version history Prisma EmailTemplateTestLog <code>email_template_test_logs</code> Test email audit logs Prisma Landing Pages LandingPage <code>landing_pages</code> GrapesJS editor output with MkDocs export Prisma PageBlock <code>page_blocks</code> Reusable block library Prisma Site Settings SiteSettings <code>site_settings</code> Global site configuration singleton Prisma Media videos <code>videos</code> Video library with metadata Drizzle compilations <code>compilations</code> Video compilation tracking Drizzle jobs <code>jobs</code> Job queue with resource management Drizzle <p>Total: 33 models (30 Prisma + 3 Drizzle)</p>"},{"location":"v2/database/schema/#auth-users","title":"Auth & Users","text":""},{"location":"v2/database/schema/#user","title":"User","text":"<p>Table: <code>users</code> Description: User accounts with role-based access control, temporary user support, and audit tracking.</p> Field Type Required Default Description id String \u2713 <code>cuid()</code> Primary key email String \u2713 \u2014 Unique email address password String \u2713 \u2014 bcrypt hashed password (12+ chars policy) name String \u2717 <code>null</code> User display name phone String \u2717 <code>null</code> Phone number role UserRole \u2713 <code>USER</code> Role: SUPER_ADMIN, INFLUENCE_ADMIN, MAP_ADMIN, USER, TEMP status UserStatus \u2713 <code>ACTIVE</code> Status: ACTIVE, INACTIVE, SUSPENDED, EXPIRED permissions Json \u2717 <code>null</code> Granular per-app permissions object createdVia UserCreatedVia \u2713 <code>STANDARD</code> Creation source: ADMIN, PUBLIC_SHIFT_SIGNUP, STANDARD expiresAt DateTime \u2717 <code>null</code> Expiration date for TEMP users expireDays Int \u2717 <code>null</code> Days until expiration for TEMP users lastLoginAt DateTime \u2717 <code>null</code> Last login timestamp emailVerified Boolean \u2713 <code>false</code> Email verification status createdAt DateTime \u2713 <code>now()</code> Creation timestamp updatedAt DateTime \u2713 Auto Last update timestamp <p>Indexes: - Unique: <code>email</code></p> <p>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[]</p>"},{"location":"v2/database/schema/#refreshtoken","title":"RefreshToken","text":"<p>Table: <code>refresh_tokens</code> Description: JWT refresh token storage with expiration tracking.</p> Field Type Required Default Description id String \u2713 <code>cuid()</code> 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 <code>now()</code> Creation timestamp <p>Indexes: - Unique: <code>token</code> - Foreign key: <code>userId</code></p> <p>Relations: - user \u2192 User (onDelete: Cascade)</p>"},{"location":"v2/database/schema/#influence","title":"Influence","text":""},{"location":"v2/database/schema/#campaign","title":"Campaign","text":"<p>Table: <code>campaigns</code> Description: Advocacy campaigns with 12 feature flags and government-level targeting.</p> Field Type Required Default Description id String \u2713 <code>cuid()</code> Primary key slug String \u2713 \u2014 URL-friendly slug (unique) title String \u2713 \u2014 Campaign title description String \u2717 <code>null</code> Campaign description (long text) emailSubject String \u2713 \u2014 Default email subject line emailBody String \u2713 \u2014 Default email body (long text) callToAction String \u2717 <code>null</code> Call-to-action text (long text) coverPhoto String \u2717 <code>null</code> Cover photo URL status CampaignStatus \u2713 <code>DRAFT</code> Status: DRAFT, ACTIVE, PAUSED, ARCHIVED allowSmtpEmail Boolean \u2713 <code>true</code> Allow SMTP email sending allowMailtoLink Boolean \u2713 <code>true</code> Allow mailto: links collectUserInfo Boolean \u2713 <code>true</code> Collect user information showEmailCount Boolean \u2713 <code>true</code> Show email sent count showCallCount Boolean \u2713 <code>true</code> Show call made count allowEmailEditing Boolean \u2713 <code>false</code> Allow users to edit email content allowCustomRecipients Boolean \u2713 <code>false</code> Allow custom email recipients showResponseWall Boolean \u2713 <code>false</code> Show public response wall highlightCampaign Boolean \u2713 <code>false</code> Highlight on campaign list page targetGovernmentLevels GovernmentLevel[] \u2713 <code>[]</code> Target levels: FEDERAL, PROVINCIAL, MUNICIPAL, SCHOOL_BOARD createdByUserId String \u2717 <code>null</code> Foreign key to User (creator) createdByUserEmail String \u2717 <code>null</code> Creator email (denormalized) createdByUserName String \u2717 <code>null</code> Creator name (denormalized) createdAt DateTime \u2713 <code>now()</code> Creation timestamp updatedAt DateTime \u2713 Auto Last update timestamp <p>Indexes: - Unique: <code>slug</code></p> <p>Relations: - createdByUser \u2192 User (onDelete: SetNull) - emails \u2192 CampaignEmail[] - responses \u2192 RepresentativeResponse[] - customRecipients \u2192 CustomRecipient[] - calls \u2192 Call[]</p>"},{"location":"v2/database/schema/#representative","title":"Representative","text":"<p>Table: <code>representatives</code> Description: Cached representative data from Represent API.</p> Field Type Required Default Description id String \u2713 <code>cuid()</code> Primary key postalCode String \u2713 \u2014 Canadian postal code (indexed) name String \u2717 <code>null</code> Representative name email String \u2717 <code>null</code> Representative email districtName String \u2717 <code>null</code> Electoral district name electedOffice String \u2717 <code>null</code> Office title partyName String \u2717 <code>null</code> Political party representativeSetName String \u2717 <code>null</code> Representative set from Represent API url String \u2717 <code>null</code> Official website URL photoUrl String \u2717 <code>null</code> Photo URL offices Json \u2717 <code>null</code> Array of office contact info objects cachedAt DateTime \u2713 <code>now()</code> Cache timestamp <p>Indexes: - Non-unique: <code>postalCode</code></p> <p>Relations: None (standalone cache)</p>"},{"location":"v2/database/schema/#campaignemail","title":"CampaignEmail","text":"<p>Table: <code>campaign_emails</code> Description: Email tracking and delivery logs for campaign emails.</p> Field Type Required Default Description id String \u2713 <code>cuid()</code> Primary key campaignId String \u2713 \u2014 Foreign key to Campaign campaignSlug String \u2713 \u2014 Denormalized campaign slug userId String \u2717 <code>null</code> Foreign key to User (sender) userEmail String \u2717 <code>null</code> Sender email userName String \u2717 <code>null</code> Sender name userPostalCode String \u2717 <code>null</code> Sender postal code recipientEmail String \u2713 \u2014 Recipient email address recipientName String \u2717 <code>null</code> Recipient name recipientTitle String \u2717 <code>null</code> Recipient title recipientLevel GovernmentLevel \u2717 <code>null</code> 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 <code>SENT</code> Status: QUEUED, SENT, FAILED, CLICKED, USER_INFO_CAPTURED senderIp String \u2717 <code>null</code> Sender IP address sentAt DateTime \u2713 <code>now()</code> Send timestamp <p>Indexes: - Foreign key: <code>campaignId</code> - Non-unique: <code>campaignSlug</code></p> <p>Relations: - campaign \u2192 Campaign (onDelete: Cascade) - user \u2192 User (onDelete: SetNull)</p>"},{"location":"v2/database/schema/#representativeresponse","title":"RepresentativeResponse","text":"<p>Table: <code>representative_responses</code> Description: Response wall submissions with moderation workflow.</p> Field Type Required Default Description id String \u2713 <code>cuid()</code> 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 <code>null</code> Representative title representativeLevel GovernmentLevel \u2713 \u2014 Government level representativeEmail String \u2717 <code>null</code> 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 <code>null</code> User comment (long text) screenshotUrl String \u2717 <code>null</code> Screenshot URL submittedByUserId String \u2717 <code>null</code> Foreign key to User submittedByName String \u2717 <code>null</code> Submitter name submittedByEmail String \u2717 <code>null</code> Submitter email isAnonymous Boolean \u2713 <code>false</code> Anonymous submission flag status ResponseStatus \u2713 <code>PENDING</code> Status: PENDING, APPROVED, REJECTED isVerified Boolean \u2713 <code>false</code> Email verification status verificationToken String \u2717 <code>null</code> Verification token verificationSentAt DateTime \u2717 <code>null</code> Verification email timestamp verifiedAt DateTime \u2717 <code>null</code> Verification timestamp verifiedBy String \u2717 <code>null</code> Email address that verified upvoteCount Int \u2713 <code>0</code> Upvote count (denormalized) submittedIp String \u2717 <code>null</code> Submitter IP address createdAt DateTime \u2713 <code>now()</code> Creation timestamp updatedAt DateTime \u2713 Auto Last update timestamp <p>Indexes: - Foreign key: <code>campaignId</code> - Non-unique: <code>campaignSlug</code></p> <p>Relations: - campaign \u2192 Campaign (onDelete: Cascade) - submittedByUser \u2192 User (onDelete: SetNull) - upvotes \u2192 ResponseUpvote[]</p>"},{"location":"v2/database/schema/#responseupvote","title":"ResponseUpvote","text":"<p>Table: <code>response_upvotes</code> Description: Upvote tracking with deduplication by user ID and IP address.</p> Field Type Required Default Description id String \u2713 <code>cuid()</code> Primary key responseId String \u2713 \u2014 Foreign key to RepresentativeResponse userId String \u2717 <code>null</code> Foreign key to User userEmail String \u2717 <code>null</code> User email (for guest upvotes) upvotedIp String \u2717 <code>null</code> Upvoter IP address <p>Indexes: - Unique: <code>[responseId, userId]</code> (prevent duplicate upvotes from logged-in users) - Unique: <code>[responseId, upvotedIp]</code> (prevent duplicate upvotes from same IP)</p> <p>Relations: - response \u2192 RepresentativeResponse (onDelete: Cascade) - user \u2192 User (onDelete: SetNull)</p>"},{"location":"v2/database/schema/#customrecipient","title":"CustomRecipient","text":"<p>Table: <code>custom_recipients</code> Description: Custom email targets for campaigns (when allowCustomRecipients enabled).</p> Field Type Required Default Description id String \u2713 <code>cuid()</code> 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 <code>null</code> Recipient title recipientOrganization String \u2717 <code>null</code> Recipient organization notes String \u2717 <code>null</code> Admin notes (long text) isActive Boolean \u2713 <code>true</code> Active status createdAt DateTime \u2713 <code>now()</code> Creation timestamp updatedAt DateTime \u2713 Auto Last update timestamp <p>Indexes: - Foreign key: <code>campaignId</code></p> <p>Relations: - campaign \u2192 Campaign (onDelete: Cascade)</p>"},{"location":"v2/database/schema/#postalcodecache","title":"PostalCodeCache","text":"<p>Table: <code>postal_code_cache</code> Description: Postal code geocoding cache for centroid lookups.</p> Field Type Required Default Description id String \u2713 <code>cuid()</code> Primary key postalCode String \u2713 \u2014 Canadian postal code (unique) city String \u2717 <code>null</code> City name province String \u2717 <code>null</code> Province code (e.g., \"AB\") centroidLat Decimal(10,8) \u2717 <code>null</code> Centroid latitude centroidLng Decimal(11,8) \u2717 <code>null</code> Centroid longitude lastUpdated DateTime \u2713 <code>now()</code> Last cache update <p>Indexes: - Unique: <code>postalCode</code></p> <p>Relations: None (standalone cache)</p>"},{"location":"v2/database/schema/#emaillog","title":"EmailLog","text":"<p>Table: <code>email_logs</code> Description: Global email audit trail (all email types).</p> Field Type Required Default Description id String \u2713 <code>cuid()</code> 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 <code>null</code> Email subject line message String \u2717 <code>null</code> Email message body (long text) postalCode String \u2717 <code>null</code> Sender postal code status String \u2713 <code>\"sent\"</code> Status: sent, failed, previewed senderIp String \u2717 <code>null</code> Sender IP address sentAt DateTime \u2713 <code>now()</code> Send timestamp <p>Indexes: None</p> <p>Relations: None (audit log only)</p>"},{"location":"v2/database/schema/#emailverification","title":"EmailVerification","text":"<p>Table: <code>email_verifications</code> Description: Email verification tokens for response wall submissions.</p> Field Type Required Default Description id String \u2713 <code>cuid()</code> Primary key token String \u2713 \u2014 Verification token (unique) email String \u2713 \u2014 Email address to verify tempCampaignData String \u2717 <code>null</code> Temporary campaign data JSON (long text) createdAt DateTime \u2713 <code>now()</code> Creation timestamp expiresAt DateTime \u2713 \u2014 Token expiration timestamp used Boolean \u2713 <code>false</code> Token used flag <p>Indexes: - Unique: <code>token</code></p> <p>Relations: None (standalone)</p>"},{"location":"v2/database/schema/#call","title":"Call","text":"<p>Table: <code>calls</code> Description: Phone call tracking for advocacy campaigns.</p> Field Type Required Default Description id String \u2713 <code>cuid()</code> Primary key representativeName String \u2713 \u2014 Representative name representativeTitle String \u2717 <code>null</code> Representative title phoneNumber String \u2713 \u2014 Phone number called officeType String \u2717 <code>null</code> Office type (constituency, legislative, etc.) callerName String \u2717 <code>null</code> Caller name callerEmail String \u2717 <code>null</code> Caller email postalCode String \u2717 <code>null</code> Caller postal code campaignId String \u2717 <code>null</code> Foreign key to Campaign campaignSlug String \u2717 <code>null</code> Denormalized campaign slug callerIp String \u2717 <code>null</code> Caller IP address calledAt DateTime \u2713 <code>now()</code> Call timestamp <p>Indexes: - Foreign key: <code>campaignId</code></p> <p>Relations: - campaign \u2192 Campaign (onDelete: SetNull)</p>"},{"location":"v2/database/schema/#map-locations","title":"Map \u2014 Locations","text":""},{"location":"v2/database/schema/#location","title":"Location","text":"<p>Table: <code>locations</code> Description: Building-level address data with geocoding and NAR integration.</p> Field Type Required Default Description id String \u2713 <code>cuid()</code> 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 <code>null</code> Canadian postal code province String \u2717 <code>null</code> Province code (e.g., \"AB\") federalDistrict String \u2717 <code>null</code> Federal electoral district name buildingUse Int \u2717 <code>null</code> NAR BU_USE: 1=Residential, 2=Partial, 3=Non-Residential, 4=Unknown locGuid String \u2717 <code>null</code> NAR LOC_GUID (unique) buildingType BuildingType \u2713 <code>SINGLE_FAMILY</code> Type: SINGLE_FAMILY, MULTI_UNIT, MIXED_USE, COMMERCIAL totalUnits Int \u2713 <code>1</code> Total units in building buildingNotes String \u2717 <code>null</code> Access codes, manager contact (long text) geocodeConfidence Int \u2717 <code>null</code> Geocoding confidence (0-100) geocodeProvider GeocodeProvider \u2717 <code>null</code> Provider: GOOGLE, MAPBOX, NOMINATIM, PHOTON, LOCATIONIQ, ARCGIS, UNKNOWN createdByUserId String \u2717 <code>null</code> Foreign key to User (creator) updatedByUserId String \u2717 <code>null</code> Foreign key to User (last updater) createdAt DateTime \u2713 <code>now()</code> Creation timestamp updatedAt DateTime \u2713 Auto Last update timestamp <p>Indexes: - Unique: <code>locGuid</code> - Composite: <code>[latitude, longitude]</code> (spatial queries) - Non-unique: <code>postalCode</code></p> <p>Relations: - createdByUser \u2192 User (onDelete: SetNull) - updatedByUser \u2192 User (onDelete: SetNull) - addresses \u2192 Address[] - history \u2192 LocationHistory[]</p>"},{"location":"v2/database/schema/#address","title":"Address","text":"<p>Table: <code>addresses</code> Description: Unit-level data with support levels and canvassing information.</p> Field Type Required Default Description id String \u2713 <code>cuid()</code> Primary key locationId String \u2713 \u2014 Foreign key to Location unitNumber String \u2717 <code>null</code> Unit/apartment number addrGuid String \u2717 <code>null</code> NAR ADDR_GUID (unique) firstName String \u2717 <code>null</code> Occupant first name lastName String \u2717 <code>null</code> Occupant last name email String \u2717 <code>null</code> Occupant email phone String \u2717 <code>null</code> Occupant phone supportLevel SupportLevel \u2717 <code>null</code> Support level: 1, 2, 3, 4 sign Boolean \u2713 <code>false</code> Sign requested flag signSize String \u2717 <code>null</code> Sign size notes String \u2717 <code>null</code> Canvassing notes (long text) createdByUserId String \u2717 <code>null</code> Foreign key to User (creator) updatedByUserId String \u2717 <code>null</code> Foreign key to User (last updater) createdAt DateTime \u2713 <code>now()</code> Creation timestamp updatedAt DateTime \u2713 Auto Last update timestamp <p>Indexes: - Unique: <code>addrGuid</code> - Foreign key: <code>locationId</code> - Composite: <code>[locationId, unitNumber]</code> (unit lookups)</p> <p>Relations: - location \u2192 Location (onDelete: Cascade) - createdByUser \u2192 User (onDelete: SetNull) - updatedByUser \u2192 User (onDelete: SetNull) - canvassVisits \u2192 CanvassVisit[]</p>"},{"location":"v2/database/schema/#locationhistory","title":"LocationHistory","text":"<p>Table: <code>location_history</code> Description: Audit trail for location changes with action types.</p> Field Type Required Default Description id String \u2713 <code>cuid()</code> Primary key locationId String \u2713 \u2014 Foreign key to Location userId String \u2717 <code>null</code> Foreign key to User action LocationHistoryAction \u2713 \u2014 Action: CREATED, UPDATED, GEOCODED, BULK_GEOCODED, MOVED_ON_MAP, IMPORTED_CSV, IMPORTED_NAR field String \u2717 <code>null</code> Field name that changed oldValue String \u2717 <code>null</code> Old value (long text) newValue String \u2717 <code>null</code> New value (long text) metadata Json \u2717 <code>null</code> Provider, confidence, etc. createdAt DateTime \u2713 <code>now()</code> Timestamp <p>Indexes: - Foreign key: <code>locationId</code> - Foreign key: <code>userId</code> - Non-unique: <code>createdAt</code> (temporal queries)</p> <p>Relations: - location \u2192 Location (onDelete: Cascade) - user \u2192 User (onDelete: SetNull)</p>"},{"location":"v2/database/schema/#map-shifts-cuts","title":"Map \u2014 Shifts & Cuts","text":""},{"location":"v2/database/schema/#shift","title":"Shift","text":"<p>Table: <code>shifts</code> Description: Volunteer shifts with scheduling and capacity tracking.</p> Field Type Required Default Description id String \u2713 <code>cuid()</code> Primary key title String \u2713 \u2014 Shift title description String \u2717 <code>null</code> 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 <code>null</code> Shift location description maxVolunteers Int \u2713 \u2014 Maximum volunteer capacity currentVolunteers Int \u2713 <code>0</code> Current signup count status ShiftStatus \u2713 <code>OPEN</code> Status: OPEN, FULL, CANCELLED isPublic Boolean \u2713 <code>false</code> Public signup allowed cutId String \u2717 <code>null</code> Foreign key to Cut createdBy String \u2717 <code>null</code> Creator user ID (string, not FK) createdAt DateTime \u2713 <code>now()</code> Creation timestamp updatedAt DateTime \u2713 Auto Last update timestamp <p>Indexes: - Foreign key: <code>cutId</code></p> <p>Relations: - cut \u2192 Cut (onDelete: SetNull) - signups \u2192 ShiftSignup[] - canvassVisits \u2192 CanvassVisit[] - canvassSessions \u2192 CanvassSession[]</p>"},{"location":"v2/database/schema/#shiftsignup","title":"ShiftSignup","text":"<p>Table: <code>shift_signups</code> Description: Shift signup tracking with source attribution.</p> Field Type Required Default Description id String \u2713 <code>cuid()</code> Primary key shiftId String \u2713 \u2014 Foreign key to Shift shiftTitle String \u2717 <code>null</code> Denormalized shift title userId String \u2717 <code>null</code> Foreign key to User userEmail String \u2713 \u2014 User email (for guest signups) userName String \u2717 <code>null</code> User name userPhone String \u2717 <code>null</code> User phone signupDate DateTime \u2713 <code>now()</code> Signup timestamp status SignupStatus \u2713 <code>CONFIRMED</code> Status: CONFIRMED, CANCELLED signupSource SignupSource \u2713 <code>AUTHENTICATED</code> Source: AUTHENTICATED, PUBLIC, ADMIN <p>Indexes: - Unique: <code>[shiftId, userEmail]</code> (prevent duplicate signups) - Foreign key: <code>shiftId</code></p> <p>Relations: - shift \u2192 Shift (onDelete: Cascade) - user \u2192 User (onDelete: SetNull)</p>"},{"location":"v2/database/schema/#cut","title":"Cut","text":"<p>Table: <code>cuts</code> Description: GeoJSON polygon overlays for map filtering and canvassing.</p> Field Type Required Default Description id String \u2713 <code>cuid()</code> Primary key name String \u2713 \u2014 Cut name description String \u2717 <code>null</code> Cut description (long text) color String \u2713 <code>\"#3388ff\"</code> Polygon fill color (hex) opacity Decimal(3,2) \u2713 <code>0.3</code> Polygon opacity (0.00-1.00) category CutCategory \u2717 <code>null</code> Category: CUSTOM, WARD, NEIGHBORHOOD, DISTRICT isPublic Boolean \u2713 <code>false</code> Public visibility flag isOfficial Boolean \u2713 <code>false</code> Official boundary flag geojson String \u2713 \u2014 GeoJSON polygon data (long text) bounds String \u2717 <code>null</code> Bounding box JSON (long text) showLocations Boolean \u2713 <code>true</code> Show locations on map exportEnabled Boolean \u2713 <code>true</code> Export enabled flag assignedTo String \u2717 <code>null</code> Assigned user ID (string, not FK) filterSettings Json \u2717 <code>null</code> Filter configuration object lastCanvassed DateTime \u2717 <code>null</code> Last canvass timestamp completionPercentage Int \u2713 <code>0</code> Canvass completion percentage createdByUserId String \u2717 <code>null</code> Foreign key to User (creator) createdAt DateTime \u2713 <code>now()</code> Creation timestamp updatedAt DateTime \u2713 Auto Last update timestamp <p>Indexes: None</p> <p>Relations: - createdByUser \u2192 User (onDelete: SetNull) - shifts \u2192 Shift[] - canvassSessions \u2192 CanvassSession[]</p>"},{"location":"v2/database/schema/#mapsettings","title":"MapSettings","text":"<p>Table: <code>map_settings</code> Description: Singleton for map center/zoom and walk sheet configuration.</p> Field Type Required Default Description id String \u2713 <code>cuid()</code> Primary key (always \"default\") latitude Decimal(10,8) \u2717 <code>null</code> Map center latitude longitude Decimal(11,8) \u2717 <code>null</code> Map center longitude zoom Int \u2717 <code>null</code> Default map zoom level walkSheetTitle String \u2717 <code>null</code> Walk sheet header title walkSheetSubtitle String \u2717 <code>null</code> Walk sheet header subtitle walkSheetFooter String \u2717 <code>null</code> Walk sheet footer text (long text) qrCode1Url String \u2717 <code>null</code> QR code 1 URL qrCode1Label String \u2717 <code>null</code> QR code 1 label qrCode2Url String \u2717 <code>null</code> QR code 2 URL qrCode2Label String \u2717 <code>null</code> QR code 2 label qrCode3Url String \u2717 <code>null</code> QR code 3 URL qrCode3Label String \u2717 <code>null</code> QR code 3 label createdBy String \u2717 <code>null</code> Creator user ID (string, not FK) createdAt DateTime \u2713 <code>now()</code> Creation timestamp updatedAt DateTime \u2713 Auto Last update timestamp <p>Indexes: None</p> <p>Relations: None (singleton)</p>"},{"location":"v2/database/schema/#canvassing","title":"Canvassing","text":""},{"location":"v2/database/schema/#canvasssession","title":"CanvassSession","text":"<p>Table: <code>canvass_sessions</code> Description: Canvassing session lifecycle with status tracking.</p> Field Type Required Default Description id String \u2713 <code>cuid()</code> Primary key userId String \u2713 \u2014 Foreign key to User cutId String \u2713 \u2014 Foreign key to Cut shiftId String \u2717 <code>null</code> Foreign key to Shift status CanvassSessionStatus \u2713 <code>ACTIVE</code> Status: ACTIVE, COMPLETED, ABANDONED startedAt DateTime \u2713 <code>now()</code> Session start timestamp endedAt DateTime \u2717 <code>null</code> Session end timestamp startLatitude Decimal(10,8) \u2717 <code>null</code> Starting latitude startLongitude Decimal(11,8) \u2717 <code>null</code> Starting longitude <p>Indexes: - Foreign key: <code>userId</code> - Foreign key: <code>cutId</code> - Foreign key: <code>shiftId</code></p> <p>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)</p>"},{"location":"v2/database/schema/#canvassvisit","title":"CanvassVisit","text":"<p>Table: <code>canvass_visits</code> Description: Visit recording with outcome tracking.</p> Field Type Required Default Description id String \u2713 <code>cuid()</code> Primary key addressId String \u2713 \u2014 Foreign key to Address userId String \u2713 \u2014 Foreign key to User shiftId String \u2717 <code>null</code> Foreign key to Shift sessionId String \u2717 <code>null</code> 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 <code>null</code> Support level: 1, 2, 3, 4 signRequested Boolean \u2713 <code>false</code> Sign requested flag signSize String \u2717 <code>null</code> Sign size notes String \u2717 <code>null</code> Visit notes (long text) durationSeconds Int \u2717 <code>null</code> Visit duration in seconds visitedAt DateTime \u2713 <code>now()</code> Visit timestamp <p>Indexes: - Foreign key: <code>addressId</code> - Foreign key: <code>userId</code> - Foreign key: <code>shiftId</code> - Foreign key: <code>sessionId</code> - Non-unique: <code>visitedAt</code> (temporal queries)</p> <p>Relations: - address \u2192 Address (onDelete: Cascade) - user \u2192 User (onDelete: Cascade) - shift \u2192 Shift (onDelete: SetNull) - session \u2192 CanvassSession (onDelete: SetNull)</p>"},{"location":"v2/database/schema/#trackingsession","title":"TrackingSession","text":"<p>Table: <code>tracking_sessions</code> Description: GPS tracking sessions with distance calculation.</p> Field Type Required Default Description id String \u2713 <code>cuid()</code> Primary key userId String \u2713 \u2014 Foreign key to User canvassSessionId String \u2717 <code>null</code> Foreign key to CanvassSession (unique, one-to-one) startedAt DateTime \u2713 <code>now()</code> Tracking start timestamp endedAt DateTime \u2717 <code>null</code> Tracking end timestamp isActive Boolean \u2713 <code>true</code> Active tracking flag totalPoints Int \u2713 <code>0</code> Total GPS points recorded totalDistanceM Float \u2713 <code>0</code> Total distance in meters lastLatitude Decimal(10,8) \u2717 <code>null</code> Last recorded latitude lastLongitude Decimal(11,8) \u2717 <code>null</code> Last recorded longitude lastRecordedAt DateTime \u2717 <code>null</code> Last GPS point timestamp <p>Indexes: - Unique: <code>canvassSessionId</code> - Foreign key: <code>userId</code> - Non-unique: <code>isActive</code> - Composite: <code>[isActive, lastRecordedAt]</code> (cleanup queries)</p> <p>Relations: - user \u2192 User (onDelete: Cascade) - canvassSession \u2192 CanvassSession (onDelete: SetNull) - trackPoints \u2192 TrackPoint[]</p>"},{"location":"v2/database/schema/#trackpoint","title":"TrackPoint","text":"<p>Table: <code>track_points</code> Description: GPS breadcrumb trail with event types.</p> Field Type Required Default Description id String \u2713 <code>cuid()</code> 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 <code>null</code> GPS accuracy in meters recordedAt DateTime \u2713 <code>now()</code> GPS point timestamp eventType TrackPointEvent \u2717 <code>null</code> Event: LOCATION_ADDED, VISIT_RECORDED, SESSION_STARTED, SESSION_ENDED <p>Indexes: - Composite: <code>[trackingSessionId, recordedAt]</code> (temporal queries) - Non-unique: <code>recordedAt</code></p> <p>Relations: - trackingSession \u2192 TrackingSession (onDelete: Cascade)</p>"},{"location":"v2/database/schema/#email-templates","title":"Email Templates","text":""},{"location":"v2/database/schema/#emailtemplate","title":"EmailTemplate","text":"<p>Table: <code>email_templates</code> Description: Email template master records with category organization.</p> Field Type Required Default Description id String \u2713 <code>cuid()</code> Primary key key String \u2713 \u2014 Template key (unique, e.g., \"campaign-email\") name String \u2713 \u2014 Display name description String \u2717 <code>null</code> 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 <code>false</code> System template (prevent deletion) isActive Boolean \u2713 <code>true</code> Active status createdByUserId String \u2713 \u2014 Foreign key to User (creator) updatedByUserId String \u2717 <code>null</code> Foreign key to User (last updater) createdAt DateTime \u2713 <code>now()</code> Creation timestamp updatedAt DateTime \u2713 Auto Last update timestamp <p>Indexes: - Unique: <code>key</code> - Non-unique: <code>category</code> - Non-unique: <code>isActive</code></p> <p>Relations: - createdBy \u2192 User - updatedBy \u2192 User - variables \u2192 EmailTemplateVariable[] - versions \u2192 EmailTemplateVersion[] - testLogs \u2192 EmailTemplateTestLog[]</p>"},{"location":"v2/database/schema/#emailtemplatevariable","title":"EmailTemplateVariable","text":"<p>Table: <code>email_template_variables</code> Description: Template variable definitions with validation.</p> Field Type Required Default Description id String \u2713 <code>cuid()</code> 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 <code>null</code> Variable description (long text) isRequired Boolean \u2713 <code>true</code> Required flag isConditional Boolean \u2713 <code>false</code> Conditional variable (used in {{#if}}) sampleValue String \u2717 <code>null</code> Sample value for testing (long text) sortOrder Int \u2713 <code>0</code> Display order <p>Indexes: - Unique: <code>[templateId, key]</code> (unique variable keys per template) - Foreign key: <code>templateId</code></p> <p>Relations: - template \u2192 EmailTemplate (onDelete: Cascade)</p>"},{"location":"v2/database/schema/#emailtemplateversion","title":"EmailTemplateVersion","text":"<p>Table: <code>email_template_versions</code> Description: Template version history with auto-increment version numbers.</p> Field Type Required Default Description id String \u2713 <code>cuid()</code> 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 <code>null</code> Version notes (long text) createdByUserId String \u2713 \u2014 Foreign key to User createdAt DateTime \u2713 <code>now()</code> Version timestamp <p>Indexes: - Unique: <code>[templateId, versionNumber]</code> (sequential version numbers) - Composite: <code>[templateId, createdAt(sort: Desc)]</code> (recent versions)</p> <p>Relations: - template \u2192 EmailTemplate (onDelete: Cascade) - createdBy \u2192 User</p>"},{"location":"v2/database/schema/#emailtemplatetestlog","title":"EmailTemplateTestLog","text":"<p>Table: <code>email_template_test_logs</code> Description: Test email audit logs.</p> Field Type Required Default Description id String \u2713 <code>cuid()</code> 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 <code>null</code> Error message (long text) messageId String \u2717 <code>null</code> Nodemailer message ID sentByUserId String \u2713 \u2014 Foreign key to User sentAt DateTime \u2713 <code>now()</code> Send timestamp <p>Indexes: - Composite: <code>[templateId, sentAt(sort: Desc)]</code> (recent tests)</p> <p>Relations: - template \u2192 EmailTemplate (onDelete: Cascade) - sentBy \u2192 User</p>"},{"location":"v2/database/schema/#landing-pages","title":"Landing Pages","text":""},{"location":"v2/database/schema/#landingpage","title":"LandingPage","text":"<p>Table: <code>landing_pages</code> Description: GrapesJS editor output with MkDocs export support.</p> Field Type Required Default Description id String \u2713 <code>cuid()</code> Primary key slug String \u2713 \u2014 URL slug (unique) title String \u2713 \u2014 Page title description String \u2717 <code>null</code> Page description (long text) blocks Json \u2713 \u2014 GrapesJS editor JSON htmlOutput String \u2717 <code>null</code> Rendered HTML (long text) cssOutput String \u2717 <code>null</code> Rendered CSS (long text) editorMode EditorMode \u2713 <code>VISUAL</code> Editor mode: VISUAL, CODE mkdocsPath String \u2717 <code>null</code> Path in mkdocs/overrides/ mkdocsStubPath String \u2717 <code>null</code> Path to .md stub in mkdocs/docs/ mkdocsExportMode MkdocsExportMode \u2713 <code>THEMED</code> Export mode: THEMED, STANDALONE mkdocsHideNav Boolean \u2713 <code>true</code> Hide navigation in MkDocs mkdocsHideToc Boolean \u2713 <code>true</code> Hide table of contents in MkDocs mkdocsSkipExport Boolean \u2713 <code>false</code> Skip MkDocs export flag published Boolean \u2713 <code>false</code> Published status seoTitle String \u2717 <code>null</code> SEO title override seoDescription String \u2717 <code>null</code> SEO description (long text) seoImage String \u2717 <code>null</code> SEO image URL createdAt DateTime \u2713 <code>now()</code> Creation timestamp updatedAt DateTime \u2713 Auto Last update timestamp <p>Indexes: - Unique: <code>slug</code></p> <p>Relations: None</p>"},{"location":"v2/database/schema/#pageblock","title":"PageBlock","text":"<p>Table: <code>page_blocks</code> Description: Reusable block library for GrapesJS editor.</p> Field Type Required Default Description id String \u2713 <code>cuid()</code> 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 <code>null</code> Thumbnail URL category String \u2717 <code>null</code> Block category sortOrder Int \u2713 <code>0</code> Display order createdAt DateTime \u2713 <code>now()</code> Creation timestamp updatedAt DateTime \u2713 Auto Last update timestamp <p>Indexes: None</p> <p>Relations: None</p>"},{"location":"v2/database/schema/#site-settings","title":"Site Settings","text":""},{"location":"v2/database/schema/#sitesettings","title":"SiteSettings","text":"<p>Table: <code>site_settings</code> Description: Global site configuration singleton for branding, theme, SMTP, and feature toggles.</p> Field Type Required Default Description id String \u2713 <code>cuid()</code> Primary key (always \"default\") organizationName String \u2713 <code>\"Changemaker Lite\"</code> Organization name organizationShortName String \u2713 <code>\"CML\"</code> Short name/acronym organizationLogoUrl String \u2717 <code>null</code> Logo URL organizationFaviconUrl String \u2717 <code>null</code> Favicon URL adminColorPrimary String \u2713 <code>\"#9d4edd\"</code> Admin primary color (hex) adminColorBgBase String \u2713 <code>\"#1a1025\"</code> Admin background color (hex) publicColorPrimary String \u2713 <code>\"#3498db\"</code> Public primary color (hex) publicColorBgBase String \u2713 <code>\"#0d1b2a\"</code> Public background color (hex) publicColorBgContainer String \u2713 <code>\"#1b2838\"</code> Public container color (hex) publicHeaderGradient String \u2713 <code>\"linear-gradient(135deg, #005a9c 0%, #007acc 100%)\"</code> Public header gradient (CSS) footerText String \u2713 <code>\"Powered by Changemaker Lite\"</code> Footer text loginSubtitle String \u2713 <code>\"Admin\"</code> Login page subtitle emailFromName String \u2713 <code>\"Changemaker Lite\"</code> Email from name smtpHost String \u2713 <code>\"\"</code> SMTP host (empty = use env) smtpPort Int \u2713 <code>0</code> SMTP port (0 = use env) smtpUser String \u2713 <code>\"\"</code> SMTP username (empty = use env) smtpPass String \u2713 <code>\"\"</code> SMTP password (empty = use env) smtpFromAddress String \u2713 <code>\"\"</code> SMTP from address (empty = use env) smtpActiveProvider String \u2713 <code>\"mailhog\"</code> Active provider: \"mailhog\", \"production\" emailTestMode Boolean \u2713 <code>true</code> Email test mode flag testEmailRecipient String \u2713 <code>\"\"</code> Test email recipient enableInfluence Boolean \u2713 <code>true</code> Enable Influence module enableMap Boolean \u2713 <code>true</code> Enable Map module enableNewsletter Boolean \u2713 <code>true</code> Enable Newsletter module enableLandingPages Boolean \u2713 <code>true</code> Enable Landing Pages module createdAt DateTime \u2713 <code>now()</code> Creation timestamp updatedAt DateTime \u2713 Auto Last update timestamp <p>Indexes: None</p> <p>Relations: None (singleton)</p>"},{"location":"v2/database/schema/#media-drizzle-orm","title":"Media (Drizzle ORM)","text":""},{"location":"v2/database/schema/#videos","title":"videos","text":"<p>Table: <code>videos</code> Description: Video library with metadata extraction and engagement tracking.</p> Field Type Required Default Description id serial \u2713 Auto Primary key (auto-increment) path text \u2713 \u2014 File path (unique) filename text \u2713 \u2014 File name producer text \u2717 <code>null</code> Producer name creator text \u2717 <code>null</code> Creator name title text \u2717 <code>null</code> Video title durationSeconds integer \u2717 <code>null</code> Duration in seconds (FFprobe) quality text \u2717 <code>null</code> Quality string (e.g., \"1080p\") orientation text \u2717 <code>null</code> Orientation: portrait, landscape, square hasAudio boolean \u2713 <code>true</code> Audio track present flag fileSize bigint \u2717 <code>null</code> File size in bytes fileHash text \u2717 <code>null</code> MD5 hash width integer \u2717 <code>null</code> Video width (FFprobe) height integer \u2717 <code>null</code> Video height (FFprobe) lastValidated timestamp \u2717 <code>null</code> Last validation timestamp isValid boolean \u2713 <code>true</code> Valid file flag thumbnailPath text \u2717 <code>null</code> Thumbnail file path createdAt timestamp \u2713 <code>now()</code> Creation timestamp tags jsonb \u2717 <code>null</code> Array of tag strings directoryType text \u2717 <code>null</code> Directory type: studios, gifs, private, inbox, curated, playback, compilations, videos, highlights publicViewCount integer \u2717 <code>null</code> Public view count (historical) publicUpvoteCount integer \u2717 <code>null</code> Public upvote count (historical) publicCommentCount integer \u2717 <code>null</code> Public comment count (historical) publicCompletionCount integer \u2717 <code>null</code> Public completion count (historical) publicTotalWatchTime integer \u2717 <code>null</code> Public total watch time (historical) movedFromPublicAt timestamp \u2717 <code>null</code> Timestamp when moved from public media originalFilename text \u2717 <code>null</code> Original filename before standardization originalPath text \u2717 <code>null</code> Original path before standardization standardizedAt timestamp \u2717 <code>null</code> Standardization timestamp <p>Indexes: - Unique: <code>path</code> - Non-unique: <code>orientation</code> - Non-unique: <code>producer</code> - Non-unique: <code>isValid</code> - Non-unique: <code>directoryType</code> - Composite: <code>[durationSeconds, fileSize, width, height]</code> (fingerprint) - Composite: <code>[directoryType, isValid, orientation]</code> (common filtering)</p> <p>Relations: None (standalone)</p>"},{"location":"v2/database/schema/#compilations","title":"compilations","text":"<p>Table: <code>compilations</code> Description: Video compilation tracking.</p> Field Type Required Default Description id serial \u2713 Auto Primary key (auto-increment) filename text \u2713 \u2014 Compilation filename path text \u2717 <code>null</code> Compilation file path durationSeconds integer \u2717 <code>null</code> Total duration in seconds videoIds jsonb \u2717 <code>null</code> Array of video IDs included settings jsonb \u2717 <code>null</code> Compilation settings object createdAt timestamp \u2713 <code>now()</code> Creation timestamp <p>Indexes: None</p> <p>Relations: None (video IDs stored as JSON array)</p>"},{"location":"v2/database/schema/#jobs","title":"jobs","text":"<p>Table: <code>jobs</code> Description: Job queue with resource category management.</p> Field Type Required Default Description id serial \u2713 Auto Primary key (auto-increment) type text \u2713 \u2014 Job type (compilation, scan, organize, etc.) status text \u2713 <code>\"pending\"</code> Status: pending, queued, running, completed, failed, cancelled progress integer \u2713 <code>0</code> Progress percentage (0-100) log text \u2717 <code>null</code> Job log output params jsonb \u2717 <code>null</code> Job parameters object startedAt timestamp \u2717 <code>null</code> Job start timestamp completedAt timestamp \u2717 <code>null</code> Job completion timestamp createdAt timestamp \u2713 <code>now()</code> Creation timestamp resourceCategory text \u2713 <code>\"cpu\"</code> Resource category: gpu_ai, gpu_encode, cpu vramRequired integer \u2713 <code>0</code> VRAM required in MB queuePosition integer \u2717 <code>null</code> Queue position waitingReason text \u2717 <code>null</code> Reason for waiting priority integer \u2713 <code>5</code> Job priority (lower = higher priority) pipelineId integer \u2717 <code>null</code> Pipeline ID (for pipeline jobs) pipelineStepId integer \u2717 <code>null</code> Pipeline step ID <p>Indexes: - Composite: <code>[status, priority, createdAt]</code> (queue processing) - Composite: <code>[resourceCategory, status]</code> (resource filtering) - Non-unique: <code>pipelineId</code></p> <p>Relations: None (pipeline relations are external)</p>"},{"location":"v2/database/schema/#related-documentation","title":"Related Documentation","text":"<ul> <li>Database Overview \u2014 Complete ER diagram and architecture</li> <li>Migration Workflow \u2014 Prisma and Drizzle migration processes</li> <li>Seeding \u2014 Default data and seed script</li> <li>Indexes \u2014 Index strategy and performance</li> <li>Auth Models \u2014 User and authentication models</li> <li>Influence Models \u2014 Campaign and advocacy models</li> <li>Map Models \u2014 Location, shift, and cut models</li> <li>Canvassing Models \u2014 Session and visit tracking</li> <li>Email Template Models \u2014 Template system models</li> <li>Landing Page Models \u2014 Page builder models</li> <li>Settings Models \u2014 Site and map settings</li> <li>Media Models \u2014 Video library models (Drizzle)</li> </ul>"},{"location":"v2/database/seeding/","title":"Database Seeding","text":""},{"location":"v2/database/seeding/#overview","title":"Overview","text":"<p>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.</p> <p>Seed Script: <code>api/prisma/seed.ts</code></p> <p>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)</p>"},{"location":"v2/database/seeding/#running-seed","title":"Running Seed","text":""},{"location":"v2/database/seeding/#development","title":"Development","text":"<pre><code>cd api\nnpm run seed\n# OR\nnpx prisma db seed\n</code></pre>"},{"location":"v2/database/seeding/#production-docker","title":"Production (Docker)","text":"<pre><code>docker compose exec api npx prisma db seed\n</code></pre>"},{"location":"v2/database/seeding/#cicd","title":"CI/CD","text":"<p>Seed runs automatically after <code>prisma migrate deploy</code> if configured in <code>package.json</code>: <pre><code>{\n \"prisma\": {\n \"seed\": \"ts-node prisma/seed.ts\"\n }\n}\n</code></pre></p>"},{"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":"<p>Email: <code>admin@cmlite.org</code> Password: <code>ChangeMe2025!</code> Role: <code>SUPER_ADMIN</code> Status: <code>ACTIVE</code> Email Verified: <code>true</code></p> <p>Code: <pre><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</code></pre></p> <p>Security Note: Change default password immediately after first login!</p>"},{"location":"v2/database/seeding/#2-default-map-settings","title":"2. Default Map Settings","text":"<p>ID: <code>default</code> (singleton) Coordinates: Edmonton, AB (53.5461\u00b0N, 113.4938\u00b0W) Zoom: 11 Walk Sheet: Blank titles/footers</p> <p>Code: <pre><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</code></pre></p>"},{"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":"<pre><code>{\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</code></pre>"},{"location":"v2/database/seeding/#text-block","title":"Text Block","text":"<pre><code>{\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</code></pre>"},{"location":"v2/database/seeding/#features-grid","title":"Features Grid","text":"<pre><code>{\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</code></pre>"},{"location":"v2/database/seeding/#call-to-action","title":"Call to Action","text":"<pre><code>{\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</code></pre>"},{"location":"v2/database/seeding/#testimonials","title":"Testimonials","text":"<pre><code>{\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</code></pre>"},{"location":"v2/database/seeding/#contact-form","title":"Contact Form","text":"<pre><code>{\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</code></pre>"},{"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":"<p>Key: <code>campaign-email</code> Category: <code>INFLUENCE</code> Variables: CAMPAIGN_TITLE, MESSAGE, USER_NAME, USER_EMAIL, POSTAL_CODE, RECIPIENT_NAME, RECIPIENT_LEVEL, ORGANIZATION_NAME, TIMESTAMP</p> <p>File Locations: - HTML: <code>api/src/templates/email/campaign-email.html</code> - Text: <code>api/src/templates/email/campaign-email.txt</code></p> <p>Seeding Logic: <pre><code>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</code></pre></p>"},{"location":"v2/database/seeding/#response-verification","title":"Response Verification","text":"<p>Key: <code>response-verification</code> Category: <code>INFLUENCE</code> Variables: CAMPAIGN_TITLE, RESPONSE_TYPE, RESPONSE_TEXT, SUBMITTER_NAME, SUBMITTED_DATE, VERIFICATION_URL, REPORT_URL, ORGANIZATION_NAME, TIMESTAMP</p>"},{"location":"v2/database/seeding/#shift-signup-confirmation","title":"Shift Signup Confirmation","text":"<p>Key: <code>shift-signup-confirmation</code> Category: <code>MAP</code> Variables: ORGANIZATION_NAME, USER_NAME, USER_EMAIL, SHIFT_TITLE, SHIFT_DATE, SHIFT_TIME, SHIFT_LOCATION, IS_NEW_USER, TEMP_PASSWORD, LOGIN_URL</p>"},{"location":"v2/database/seeding/#shift-details-reminder","title":"Shift Details Reminder","text":"<p>Key: <code>shift-details</code> Category: <code>MAP</code> 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</p>"},{"location":"v2/database/seeding/#seed-script-structure","title":"Seed Script Structure","text":""},{"location":"v2/database/seeding/#main-function","title":"Main Function","text":"<pre><code>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</code></pre>"},{"location":"v2/database/seeding/#upsert-pattern","title":"Upsert Pattern","text":"<p>All seed operations use <code>upsert</code> to be idempotent: <pre><code>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</code></pre></p> <p>Benefits: - Safe to run multiple times - Won't duplicate data - Won't overwrite user changes (empty <code>update</code> clause)</p>"},{"location":"v2/database/seeding/#error-handling","title":"Error Handling","text":"<pre><code>main()\n .catch((e) => {\n console.error('Seed error:', e);\n process.exit(1);\n })\n .finally(async () => {\n await prisma.$disconnect();\n });\n</code></pre>"},{"location":"v2/database/seeding/#customizing-seed-data","title":"Customizing Seed Data","text":""},{"location":"v2/database/seeding/#change-admin-credentials","title":"Change Admin Credentials","text":"<p>Edit <code>api/prisma/seed.ts</code>: <pre><code>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</code></pre></p>"},{"location":"v2/database/seeding/#change-map-default-location","title":"Change Map Default Location","text":"<p>Edit <code>api/prisma/seed.ts</code>: <pre><code>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</code></pre></p>"},{"location":"v2/database/seeding/#add-custom-page-blocks","title":"Add Custom Page Blocks","text":"<pre><code>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</code></pre>"},{"location":"v2/database/seeding/#verifying-seed-data","title":"Verifying Seed Data","text":""},{"location":"v2/database/seeding/#check-admin-user","title":"Check Admin User","text":"<pre><code>docker compose exec api npx prisma studio\n# Navigate to users table, filter by role = \"SUPER_ADMIN\"\n</code></pre>"},{"location":"v2/database/seeding/#check-map-settings","title":"Check Map Settings","text":"<pre><code>docker compose exec v2-postgres psql -U changemaker_v2 changemaker_v2 -c \"SELECT * FROM map_settings;\"\n</code></pre>"},{"location":"v2/database/seeding/#check-page-blocks","title":"Check Page Blocks","text":"<pre><code>docker compose exec v2-postgres psql -U changemaker_v2 changemaker_v2 -c \"SELECT id, type, label FROM page_blocks ORDER BY sort_order;\"\n</code></pre>"},{"location":"v2/database/seeding/#check-email-templates","title":"Check Email Templates","text":"<pre><code>docker compose exec v2-postgres psql -U changemaker_v2 changemaker_v2 -c \"SELECT key, name, category FROM email_templates;\"\n</code></pre>"},{"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":"<p>Cause: Admin user already exists Solution: Seed uses <code>upsert</code>, so this shouldn't happen. Check seed script for typos.</p>"},{"location":"v2/database/seeding/#error-template-files-not-found","title":"Error: \"Template files not found\"","text":"<p>Cause: Email template <code>.html</code>/<code>.txt</code> files missing Solution: Ensure <code>api/src/templates/email/</code> directory contains: - <code>campaign-email.html</code> - <code>campaign-email.txt</code> - <code>response-verification.html</code> - <code>response-verification.txt</code> - <code>shift-signup-confirmation.html</code> - <code>shift-signup-confirmation.txt</code> - <code>shift-details.html</code> - <code>shift-details.txt</code></p>"},{"location":"v2/database/seeding/#error-cannot-find-module-bcryptjs","title":"Error: \"Cannot find module 'bcryptjs'\"","text":"<p>Cause: Dependencies not installed Solution: <pre><code>cd api && npm install\n</code></pre></p>"},{"location":"v2/database/seeding/#seed-doesnt-run-after-migration","title":"Seed doesn't run after migration","text":"<p>Cause: <code>package.json</code> missing <code>prisma.seed</code> config Solution: Add to <code>api/package.json</code>: <pre><code>{\n \"prisma\": {\n \"seed\": \"ts-node prisma/seed.ts\"\n }\n}\n</code></pre></p>"},{"location":"v2/database/seeding/#production-seeding","title":"Production Seeding","text":""},{"location":"v2/database/seeding/#initial-deployment","title":"Initial Deployment","text":"<pre><code># 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</code></pre>"},{"location":"v2/database/seeding/#subsequent-deployments","title":"Subsequent Deployments","text":"<p>Don't re-run seed unless adding new seed data (new page blocks, email templates, etc.). Existing seed data uses <code>upsert</code> with empty <code>update</code> clause, so it won't overwrite user changes.</p>"},{"location":"v2/database/seeding/#related-documentation","title":"Related Documentation","text":"<ul> <li>Database Overview \u2014 Complete ER diagram</li> <li>Schema Reference \u2014 All model fields</li> <li>Migration Workflow \u2014 Prisma migrations</li> <li>Auth Models \u2014 User model details</li> <li>Settings Models \u2014 MapSettings details</li> <li>Landing Page Models \u2014 PageBlock details</li> <li>Email Template Models \u2014 EmailTemplate details</li> </ul>"},{"location":"v2/database/models/","title":"Database Models","text":"<p>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).</p>"},{"location":"v2/database/models/#model-organization","title":"Model Organization","text":"<p>Models are organized by feature area:</p>"},{"location":"v2/database/models/#authentication-users","title":"Authentication & Users","text":"<p>Core authentication and user management:</p> <ul> <li>User - User accounts with roles and authentication</li> <li>RefreshToken - JWT refresh token tracking</li> <li>Session - User session management (future)</li> </ul>"},{"location":"v2/database/models/#influence-module","title":"Influence Module","text":"<p>Advocacy campaign models:</p> <ul> <li>Campaign - Campaign definitions and settings</li> <li>CampaignEmail - Sent email tracking</li> <li>Response - Public response wall submissions</li> <li>PostalCodeCache - Representative lookup cache</li> </ul>"},{"location":"v2/database/models/#map-module","title":"Map Module","text":"<p>Location and geographic models:</p> <ul> <li>Location - Address database with geocoding</li> <li>Cut - Geographic polygon organization</li> <li>Shift - Volunteer shift scheduling</li> <li>MapSettings - Map configuration singleton</li> </ul>"},{"location":"v2/database/models/#canvassing","title":"Canvassing","text":"<p>Door-to-door canvassing models:</p> <ul> <li>CanvassSession - Canvassing session tracking</li> <li>CanvassVisit - Visit outcome recording</li> <li>TrackingSession - GPS tracking (future)</li> </ul>"},{"location":"v2/database/models/#content-management","title":"Content Management","text":"<p>Landing pages and content:</p> <ul> <li>Page - Landing page definitions</li> <li>PageBlock - Reusable content blocks</li> </ul>"},{"location":"v2/database/models/#email-templates","title":"Email Templates","text":"<p>Email template system:</p> <ul> <li>EmailTemplate - Template definitions</li> <li>EmailTemplateVersion - Version history (future)</li> </ul>"},{"location":"v2/database/models/#media","title":"Media","text":"<p>Video library (Drizzle ORM):</p> <ul> <li>videos - Video metadata and files</li> <li>shared_media - Public gallery assignments</li> <li>media_reactions - Emoji reactions</li> <li>media_jobs - Background job queue</li> </ul>"},{"location":"v2/database/models/#settings","title":"Settings","text":"<p>Global configuration:</p> <ul> <li>Settings - Site-wide settings singleton</li> </ul>"},{"location":"v2/database/models/#orm-architecture","title":"ORM Architecture","text":""},{"location":"v2/database/models/#prisma-main-api","title":"Prisma (Main API)","text":"<p>Used for 95% of models:</p> <ul> <li>Schema: <code>api/prisma/schema.prisma</code></li> <li>Migrations: <code>api/prisma/migrations/</code></li> <li>Client: Auto-generated TypeScript types</li> <li>Database: PostgreSQL 16</li> </ul>"},{"location":"v2/database/models/#drizzle-media-api","title":"Drizzle (Media API)","text":"<p>Used for media models only:</p> <ul> <li>Schema: <code>api/src/modules/media/db/schema.ts</code></li> <li>Migrations: None (push-based)</li> <li>Client: Manual schema definition</li> <li>Database: Same PostgreSQL 16</li> </ul>"},{"location":"v2/database/models/#common-patterns","title":"Common Patterns","text":""},{"location":"v2/database/models/#timestamps","title":"Timestamps","text":"<p>Most models include:</p> <pre><code>createdAt DateTime @default(now())\nupdatedAt DateTime @updatedAt\n</code></pre>"},{"location":"v2/database/models/#foreign-keys","title":"Foreign Keys","text":"<p>Relations use explicit foreign key fields:</p> <pre><code>model Campaign {\n id Int @id @default(autoincrement())\n createdByUserId Int\n createdBy User @relation(fields: [createdByUserId], references: [id])\n}\n</code></pre>"},{"location":"v2/database/models/#json-fields","title":"JSON Fields","text":"<p>Flexible data stored as JSON:</p> <pre><code>model Campaign {\n emailTemplate Json?\n settings Json?\n}\n</code></pre> <p>TypeScript types:</p> <pre><code>import { Prisma } from '@prisma/client';\n\nconst template: Prisma.InputJsonValue = {\n subject: 'Email subject',\n body: 'Email body',\n};\n</code></pre>"},{"location":"v2/database/models/#enums","title":"Enums","text":"<p>Type-safe enumerations:</p> <pre><code>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</code></pre>"},{"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":"<pre><code># 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</code></pre>"},{"location":"v2/database/models/#schema-push-drizzle","title":"Schema Push (Drizzle)","text":"<pre><code># Push schema changes (media API)\ncd api && npx drizzle-kit push\n</code></pre>"},{"location":"v2/database/models/#database-browser","title":"Database Browser","text":"<p>View data via:</p> <ul> <li>Prisma Studio: <code>npx prisma studio</code></li> <li>NocoDB: http://localhost:8091 (read-only)</li> </ul>"},{"location":"v2/database/models/#indexes","title":"Indexes","text":"<p>Key indexes for performance:</p> <pre><code>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</code></pre>"},{"location":"v2/database/models/#constraints","title":"Constraints","text":""},{"location":"v2/database/models/#unique-constraints","title":"Unique Constraints","text":"<pre><code>model User {\n email String @unique\n}\n\nmodel Page {\n slug String @unique\n}\n\nmodel Cut {\n name String @unique\n}\n</code></pre>"},{"location":"v2/database/models/#check-constraints","title":"Check Constraints","text":"<p>Enforced at application level:</p> <ul> <li>Email format validation</li> <li>Password complexity (12+ chars)</li> <li>Coordinate bounds (-90 to 90 lat, -180 to 180 lng)</li> </ul>"},{"location":"v2/database/models/#relations","title":"Relations","text":""},{"location":"v2/database/models/#one-to-many","title":"One-to-Many","text":"<pre><code>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</code></pre>"},{"location":"v2/database/models/#many-to-many","title":"Many-to-Many","text":"<p>Via junction tables:</p> <pre><code>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</code></pre>"},{"location":"v2/database/models/#seeding","title":"Seeding","text":"<p>Initial data in <code>api/prisma/seed.ts</code>:</p> <ul> <li>Admin user (admin@example.com)</li> <li>Default settings</li> <li>Sample page blocks</li> <li>System email templates</li> </ul> <pre><code># Run seed\ncd api && npx prisma db seed\n</code></pre>"},{"location":"v2/database/models/#data-types","title":"Data Types","text":""},{"location":"v2/database/models/#common-types","title":"Common Types","text":"<ul> <li>ID: <code>Int @id @default(autoincrement())</code></li> <li>String: <code>String</code> or <code>String @db.Text</code> (long text)</li> <li>Number: <code>Int</code> or <code>Float</code></li> <li>Boolean: <code>Boolean @default(false)</code></li> <li>Date: <code>DateTime @default(now())</code></li> <li>JSON: <code>Json</code> or <code>Json?</code></li> <li>Enum: <code>Role</code>, <code>VisitOutcome</code>, etc.</li> </ul>"},{"location":"v2/database/models/#spatial-data","title":"Spatial Data","text":"<p>GeoJSON stored as JSON:</p> <pre><code>model Cut {\n geometry Json // GeoJSON Polygon\n}\n</code></pre> <p>Coordinates as separate fields:</p> <pre><code>model Location {\n latitude Float\n longitude Float\n}\n</code></pre>"},{"location":"v2/database/models/#database-configuration","title":"Database Configuration","text":""},{"location":"v2/database/models/#connection-string","title":"Connection String","text":"<pre><code>DATABASE_URL=\"postgresql://user:password@localhost:5432/changemaker_v2?schema=public\"\n</code></pre>"},{"location":"v2/database/models/#connection-pool","title":"Connection Pool","text":"<p>Prisma connection pool:</p> <pre><code>// api/src/server.ts\nconst prisma = new PrismaClient({\n log: ['error', 'warn'],\n});\n</code></pre>"},{"location":"v2/database/models/#related-documentation","title":"Related Documentation","text":"<ul> <li>Authentication Models</li> <li>Influence Models</li> <li>Map Models</li> <li>Canvassing Models</li> <li>Content Models</li> <li>Email Template Models</li> <li>Media Models</li> <li>Settings Models</li> <li>Database Overview</li> <li>Migrations Guide</li> <li>Backend Modules</li> </ul>"},{"location":"v2/database/models/auth/","title":"Auth & Users Models","text":""},{"location":"v2/database/models/auth/#overview","title":"Overview","text":"<p>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.</p> <p>Models: - User \u2014 User accounts with roles and permissions - RefreshToken \u2014 JWT refresh token storage with expiration</p> <p>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)</p>"},{"location":"v2/database/models/auth/#models-summary","title":"Models Summary","text":"Model Table Description User <code>users</code> User accounts with RBAC, permissions, temp user support RefreshToken <code>refresh_tokens</code> 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":"<p>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.</p>"},{"location":"v2/database/models/auth/#fields","title":"Fields","text":"Field Type Required Default Description Identity id String \u2713 <code>cuid()</code> 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 <code>null</code> User display name phone String \u2717 <code>null</code> Phone number Authorization role UserRole \u2713 <code>USER</code> User role (see enum below) status UserStatus \u2713 <code>ACTIVE</code> Account status (see enum below) permissions Json \u2717 <code>null</code> Granular per-app permissions object User Lifecycle createdVia UserCreatedVia \u2713 <code>STANDARD</code> Creation source (see enum below) expiresAt DateTime \u2717 <code>null</code> Expiration date for TEMP users expireDays Int \u2717 <code>null</code> Days until expiration (for TEMP users) lastLoginAt DateTime \u2717 <code>null</code> Last login timestamp emailVerified Boolean \u2713 <code>false</code> Email verification status Audit createdAt DateTime \u2713 <code>now()</code> 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":"<p>Role hierarchy (descending):</p> <pre><code>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</code></pre> <p>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</p>"},{"location":"v2/database/models/auth/#userstatus","title":"UserStatus","text":"<pre><code>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</code></pre>"},{"location":"v2/database/models/auth/#usercreatedvia","title":"UserCreatedVia","text":"<pre><code>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</code></pre>"},{"location":"v2/database/models/auth/#relations-33-total","title":"Relations (33 total)","text":"<p>Authentication: - <code>refreshTokens</code> \u2192 RefreshToken[] (onDelete: Cascade)</p> <p>Influence Module (6): - <code>campaignsCreated</code> \u2192 Campaign[] (creator, onDelete: SetNull) - <code>campaignEmails</code> \u2192 CampaignEmail[] (sender, onDelete: SetNull) - <code>responses</code> \u2192 RepresentativeResponse[] (submitter, onDelete: SetNull) - <code>responseUpvotes</code> \u2192 ResponseUpvote[] (onDelete: SetNull)</p> <p>Map Module (8): - <code>locationsCreated</code> \u2192 Location[] (creator, onDelete: SetNull) - <code>locationsUpdated</code> \u2192 Location[] (updater, onDelete: SetNull) - <code>addressesCreated</code> \u2192 Address[] (creator, onDelete: SetNull) - <code>addressesUpdated</code> \u2192 Address[] (updater, onDelete: SetNull) - <code>locationEdits</code> \u2192 LocationHistory[] (editor, onDelete: SetNull) - <code>cutsCreated</code> \u2192 Cut[] (creator, onDelete: SetNull) - <code>shiftSignups</code> \u2192 ShiftSignup[] (onDelete: SetNull)</p> <p>Canvassing Module (4): - <code>canvassVisits</code> \u2192 CanvassVisit[] (visitor, onDelete: Cascade) - <code>canvassSessions</code> \u2192 CanvassSession[] (onDelete: Cascade) - <code>trackingSessions</code> \u2192 TrackingSession[] (onDelete: Cascade)</p> <p>Email Templates Module (4): - <code>templatesCreated</code> \u2192 EmailTemplate[] (creator) - <code>templatesUpdated</code> \u2192 EmailTemplate[] (updater) - <code>templateVersionsCreated</code> \u2192 EmailTemplateVersion[] - <code>templateTestsSent</code> \u2192 EmailTemplateTestLog[]</p>"},{"location":"v2/database/models/auth/#indexes","title":"Indexes","text":"<ul> <li>Unique: <code>email</code> (case-insensitive via Prisma transform)</li> </ul>"},{"location":"v2/database/models/auth/#constraints","title":"Constraints","text":"<ul> <li>Email must be unique across all users</li> <li>Password must meet policy: 12+ chars, 1 uppercase, 1 lowercase, 1 digit (enforced by Zod schema)</li> <li>TEMP users must have <code>expiresAt</code> set</li> <li>EXPIRED status auto-applied when <code>expiresAt</code> < now()</li> </ul>"},{"location":"v2/database/models/auth/#refreshtoken-model","title":"RefreshToken Model","text":""},{"location":"v2/database/models/auth/#purpose_1","title":"Purpose","text":"<p>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.</p>"},{"location":"v2/database/models/auth/#fields_1","title":"Fields","text":"Field Type Required Default Description id String \u2713 <code>cuid()</code> 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 <code>now()</code> Token creation timestamp"},{"location":"v2/database/models/auth/#relations","title":"Relations","text":"<ul> <li><code>user</code> \u2192 User (onDelete: Cascade) \u2014 deleting user deletes all refresh tokens</li> </ul>"},{"location":"v2/database/models/auth/#indexes_1","title":"Indexes","text":"<ul> <li>Unique: <code>token</code> (fast lookup for refresh endpoint)</li> <li>Foreign Key: <code>userId</code> (join to User)</li> </ul>"},{"location":"v2/database/models/auth/#constraints_1","title":"Constraints","text":"<ul> <li>Token must be unique (prevents replay attacks)</li> <li>ExpiresAt must be > now() for valid tokens</li> <li>Expired tokens cleaned up via cron job (daily)</li> </ul>"},{"location":"v2/database/models/auth/#relationships-diagram","title":"Relationships Diagram","text":"<pre><code>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 }</code></pre>"},{"location":"v2/database/models/auth/#common-queries","title":"Common Queries","text":""},{"location":"v2/database/models/auth/#create-user-admin","title":"Create User (Admin)","text":"<pre><code>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</code></pre>"},{"location":"v2/database/models/auth/#create-temp-user-public-shift-signup","title":"Create Temp User (Public Shift Signup)","text":"<pre><code>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</code></pre>"},{"location":"v2/database/models/auth/#find-user-with-relations","title":"Find User with Relations","text":"<pre><code>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</code></pre>"},{"location":"v2/database/models/auth/#update-last-login","title":"Update Last Login","text":"<pre><code>await prisma.user.update({\n where: { id: userId },\n data: { lastLoginAt: new Date() },\n});\n</code></pre>"},{"location":"v2/database/models/auth/#store-refresh-token","title":"Store Refresh Token","text":"<pre><code>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</code></pre>"},{"location":"v2/database/models/auth/#refresh-token-rotation-atomic","title":"Refresh Token Rotation (Atomic)","text":"<pre><code>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</code></pre>"},{"location":"v2/database/models/auth/#expire-temp-users-cron","title":"Expire Temp Users (Cron)","text":"<pre><code>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</code></pre>"},{"location":"v2/database/models/auth/#clean-expired-refresh-tokens-cron","title":"Clean Expired Refresh Tokens (Cron)","text":"<pre><code>await prisma.refreshToken.deleteMany({\n where: {\n expiresAt: { lt: new Date() },\n },\n});\n</code></pre>"},{"location":"v2/database/models/auth/#data-flow","title":"Data Flow","text":""},{"location":"v2/database/models/auth/#user-registration-flow","title":"User Registration Flow","text":"<pre><code>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 }</code></pre>"},{"location":"v2/database/models/auth/#login-flow","title":"Login Flow","text":"<pre><code>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</code></pre>"},{"location":"v2/database/models/auth/#token-refresh-flow","title":"Token Refresh Flow","text":"<pre><code>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</code></pre>"},{"location":"v2/database/models/auth/#performance-notes","title":"Performance Notes","text":""},{"location":"v2/database/models/auth/#index-usage","title":"Index Usage","text":"<ul> <li>email unique index: Used for login lookups (<code>WHERE email = ?</code>)</li> <li>refreshToken.token unique index: Used for refresh endpoint (<code>WHERE token = ?</code>)</li> <li>refreshToken.userId index: Used for user deletion cascades</li> </ul>"},{"location":"v2/database/models/auth/#query-optimization","title":"Query Optimization","text":"<ul> <li>Avoid loading all 33 user relations by default \u2014 use selective <code>include</code> or <code>select</code></li> <li>Use <code>findFirst</code> instead of <code>findMany().take(1)</code> for single record queries</li> <li>Paginate user lists with <code>skip</code> + <code>take</code> + cursor-based pagination for large datasets</li> </ul>"},{"location":"v2/database/models/auth/#n1-prevention","title":"N+1 Prevention","text":"<pre><code>// \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</code></pre>"},{"location":"v2/database/models/auth/#security-considerations","title":"Security Considerations","text":""},{"location":"v2/database/models/auth/#password-policy","title":"Password Policy","text":"<p>Enforced at API schema level (<code>auth.schemas.ts</code>): <pre><code>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</code></pre></p>"},{"location":"v2/database/models/auth/#user-enumeration-prevention","title":"User Enumeration Prevention","text":"<ul> <li><code>/api/auth/me</code> returns 401 (not 404) for missing users</li> <li>Login endpoint returns generic \"Invalid credentials\" (not \"Email not found\")</li> <li>Registration endpoint returns generic \"Email already exists\" (no user details)</li> </ul>"},{"location":"v2/database/models/auth/#refresh-token-security","title":"Refresh Token Security","text":"<ul> <li>Tokens stored in database (not just signed JWTs)</li> <li>Rotation on every refresh (old token deleted)</li> <li>Atomic transaction prevents race conditions</li> <li>7-day expiration with daily cleanup cron</li> </ul>"},{"location":"v2/database/models/auth/#role-based-access-control","title":"Role-Based Access Control","text":"<p>Middleware enforces role requirements: <pre><code>// 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</code></pre></p>"},{"location":"v2/database/models/auth/#temp-user-restrictions","title":"TEMP User Restrictions","text":"<ul> <li>Cannot access admin routes (blocked by <code>requireNonTemp</code> middleware)</li> <li>Cannot create campaigns, locations, or templates</li> <li>Can only canvass within assigned cut (verified by canvass service)</li> <li>Auto-expire after <code>expireDays</code> (default 30)</li> </ul>"},{"location":"v2/database/models/auth/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/database/models/auth/#email-already-exists-on-registration","title":"\"Email already exists\" on registration","text":"<p>Cause: Email uniqueness constraint violated Solution: Check for existing user: <code>prisma.user.findUnique({ where: { email } })</code></p>"},{"location":"v2/database/models/auth/#invalid-refresh-token-on-refresh","title":"\"Invalid refresh token\" on refresh","text":"<p>Cause: Token already used (rotation), expired, or manually deleted Solution: User must re-login to obtain new token pair</p>"},{"location":"v2/database/models/auth/#password-does-not-meet-policy-on-update","title":"\"Password does not meet policy\" on update","text":"<p>Cause: Password validation regex mismatch Solution: Ensure new password has 12+ chars, 1 uppercase, 1 lowercase, 1 digit</p>"},{"location":"v2/database/models/auth/#temp-user-cannot-access-route","title":"TEMP user cannot access route","text":"<p>Cause: Route uses <code>requireNonTemp</code> middleware Solution: Upgrade user to <code>USER</code> role via admin panel</p>"},{"location":"v2/database/models/auth/#circular-dependency-auth-store-api-client","title":"Circular dependency: auth store \u2194 api client","text":"<p>Cause: Both modules import each other Solution: Use callback registration pattern (see <code>admin/src/lib/api.ts</code> + <code>admin/src/stores/auth.store.ts</code>)</p>"},{"location":"v2/database/models/auth/#related-documentation","title":"Related Documentation","text":"<ul> <li>Database Overview \u2014 Complete ER diagram</li> <li>Schema Reference \u2014 All model fields</li> <li>Influence Models \u2014 Campaign relations</li> <li>Map Models \u2014 Location relations</li> <li>Canvassing Models \u2014 Session relations</li> <li>Email Template Models \u2014 Template relations</li> <li>API Auth Routes \u2014 Authentication endpoints</li> <li>Security Audit \u2014 Security findings and fixes</li> </ul>"},{"location":"v2/database/models/canvass/","title":"Canvassing Models","text":""},{"location":"v2/database/models/canvass/#overview","title":"Overview","text":"<p>The Canvassing module provides GPS-tracked volunteer canvassing with session management, visit recording, walking route algorithms, and automatic session abandonment.</p> <p>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</p> <p>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)</p> <p>See Schema Reference for complete field listings.</p>"},{"location":"v2/database/models/canvass/#session-lifecycle","title":"Session Lifecycle","text":"<pre><code>stateDiagram-v2\n [*] --> ACTIVE : Start session\n ACTIVE --> COMPLETED : End session (user action)\n ACTIVE --> ABANDONED : 12h timeout (cron)\n COMPLETED --> [*]\n ABANDONED --> [*]</code></pre> <p>Status: <code>CanvassSessionStatus</code> - <code>ACTIVE</code> \u2014 Session in progress - <code>COMPLETED</code> \u2014 Session ended by user - <code>ABANDONED</code> \u2014 Session inactive > 12h (auto-expired by cron)</p>"},{"location":"v2/database/models/canvass/#visit-outcomes","title":"Visit Outcomes","text":"<pre><code>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</code></pre> <p>Support Level Mapping: - Outcome: <code>SPOKE_WITH</code> \u2192 Record support level (1-4) - Outcome: <code>REFUSED</code> \u2192 Support level defaults to <code>null</code> or <code>1</code> - Outcome: <code>NOT_HOME</code> \u2192 No support level</p>"},{"location":"v2/database/models/canvass/#walking-route-algorithm","title":"Walking Route Algorithm","text":"<p>Algorithm: Nearest-neighbor with haversine distance calculation</p> <p>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</p> <p>Implementation: <code>api/src/modules/map/canvass/walking-route.service.ts</code></p> <pre><code>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</code></pre>"},{"location":"v2/database/models/canvass/#gps-tracking","title":"GPS Tracking","text":"<p>TrackingSession = One-to-one with CanvassSession - Stores total points, distance, last position - <code>isActive</code> flag for active tracking</p> <p>TrackPoint = GPS breadcrumb - Latitude, longitude, accuracy - Event type markers (LOCATION_ADDED, VISIT_RECORDED, SESSION_STARTED, SESSION_ENDED)</p> <p>Event Flow: <pre><code>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)</code></pre></p>"},{"location":"v2/database/models/canvass/#session-abandonment","title":"Session Abandonment","text":"<p>Cron Job: Runs hourly via <code>api/src/server.ts</code> startup + interval</p> <pre><code>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</code></pre> <p>Trigger Conditions: - Status = <code>ACTIVE</code> - StartedAt < 12 hours ago - No explicit end by user</p>"},{"location":"v2/database/models/canvass/#common-queries","title":"Common Queries","text":""},{"location":"v2/database/models/canvass/#start-canvass-session","title":"Start Canvass Session","text":"<pre><code>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</code></pre>"},{"location":"v2/database/models/canvass/#record-visit","title":"Record Visit","text":"<pre><code>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</code></pre>"},{"location":"v2/database/models/canvass/#end-session","title":"End Session","text":"<pre><code>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</code></pre>"},{"location":"v2/database/models/canvass/#get-walking-route","title":"Get Walking Route","text":"<pre><code>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</code></pre>"},{"location":"v2/database/models/canvass/#related-documentation","title":"Related Documentation","text":"<ul> <li>Schema Reference \u2014 Complete field listings</li> <li>Database Overview \u2014 ER diagram</li> <li>API Canvass Routes \u2014 REST endpoints</li> <li>Volunteer Canvass Map \u2014 Full-screen canvass UI</li> <li>Admin Canvass Dashboard \u2014 Admin oversight UI</li> </ul>"},{"location":"v2/database/models/email-templates/","title":"Email Template Models","text":""},{"location":"v2/database/models/email-templates/#overview","title":"Overview","text":"<p>The Email Template module provides a reusable template system with Handlebars-style variable interpolation, version history, and test email functionality.</p> <p>Models (4): - EmailTemplate \u2014 Template master with categories - EmailTemplateVariable \u2014 Variable definitions - EmailTemplateVersion \u2014 Version history - EmailTemplateTestLog \u2014 Test email audit</p> <p>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</p> <p>See Schema Reference for complete field listings.</p>"},{"location":"v2/database/models/email-templates/#template-categories","title":"Template Categories","text":"<pre><code>enum EmailTemplateCategory {\n INFLUENCE // Campaign emails, response verification\n MAP // Shift confirmations, reminders\n SYSTEM // Password resets, welcome emails\n}\n</code></pre>"},{"location":"v2/database/models/email-templates/#variable-interpolation","title":"Variable Interpolation","text":"<p>Syntax: Handlebars-style <code>{{VARIABLE_NAME}}</code></p> <p>Example Template: <pre><code><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</code></pre></p> <p>Variable Record: <pre><code>{\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</code></pre></p>"},{"location":"v2/database/models/email-templates/#version-history","title":"Version History","text":"<p>Auto-Increment Version Numbers: <pre><code>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</code></pre></p>"},{"location":"v2/database/models/email-templates/#system-templates-4-seeded","title":"System Templates (4 seeded)","text":"<p>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</p> <p>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</p> <p>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</p> <p>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</p>"},{"location":"v2/database/models/email-templates/#related-documentation","title":"Related Documentation","text":"<ul> <li>Schema Reference \u2014 Complete field listings</li> <li>Seeding \u2014 Default templates</li> <li>API Email Template Routes \u2014 REST endpoints</li> </ul>"},{"location":"v2/database/models/influence/","title":"Influence Models","text":""},{"location":"v2/database/models/influence/#overview","title":"Overview","text":"<p>The Influence module provides advocacy campaign management with multi-government-level targeting, email/call tracking, response wall with moderation, and representative caching.</p> <p>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</p> <p>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</p> <p>See Schema Reference for complete field listings.</p>"},{"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":"<pre><code>enum GovernmentLevel {\n FEDERAL\n PROVINCIAL\n MUNICIPAL\n SCHOOL_BOARD\n}\n</code></pre> <p>Campaigns can target multiple levels: <pre><code>const campaign = await prisma.campaign.create({\n data: {\n title: 'Support Climate Action',\n targetGovernmentLevels: [GovernmentLevel.FEDERAL, GovernmentLevel.PROVINCIAL],\n // ...\n },\n});\n</code></pre></p> <p>Representative lookup filters by targeted levels: <pre><code>const reps = await representativeService.lookup(postalCode, campaign.targetGovernmentLevels);\n</code></pre></p>"},{"location":"v2/database/models/influence/#email-methods","title":"Email Methods","text":""},{"location":"v2/database/models/influence/#smtp-async-queue","title":"SMTP (Async Queue)","text":"<ul> <li>Queued via BullMQ (Redis backend)</li> <li>Worker sends via Nodemailer</li> <li>Supports templates with variable interpolation</li> <li>Tracks delivery status (QUEUED \u2192 SENT/FAILED)</li> <li>Rate limiting (10 emails/min per IP)</li> </ul>"},{"location":"v2/database/models/influence/#mailto-client-side","title":"MAILTO (Client-Side)","text":"<ul> <li>Generates mailto: link with pre-filled subject/body</li> <li>Tracked when link clicked (status: CLICKED)</li> <li>No server-side email sending</li> <li>User's default email client used</li> </ul>"},{"location":"v2/database/models/influence/#response-moderation-workflow","title":"Response Moderation Workflow","text":"<pre><code>stateDiagram-v2\n [*] --> PENDING : Submit response\n PENDING --> APPROVED : Admin approves\n PENDING --> REJECTED : Admin rejects\n APPROVED --> [*]\n REJECTED --> [*]</code></pre> <p>Status: <code>PENDING</code> (default) \u2192 <code>APPROVED</code> | <code>REJECTED</code></p> <p>Admin moderation via <code>/app/influence/responses</code>: - Filter by status, campaign, date range - Bulk approve/reject - View submitter details - Screenshot attachments</p>"},{"location":"v2/database/models/influence/#upvote-deduplication","title":"Upvote Deduplication","text":"<p>Two unique constraints prevent duplicate upvotes:</p> <pre><code>model ResponseUpvote {\n @@unique([responseId, userId]) // Logged-in users\n @@unique([responseId, upvotedIp]) // Guest users\n}\n</code></pre> <p>Logic: - Logged-in user: Check <code>[responseId, userId]</code> - Guest user: Check <code>[responseId, upvotedIp]</code> - Database-level enforcement (no race conditions)</p>"},{"location":"v2/database/models/influence/#represent-api-integration","title":"Represent API Integration","text":"<p>Representative Cache: - Cached in <code>representatives</code> table - TTL: 30 days (check <code>cachedAt</code> field) - Re-fetched if cache miss or stale</p> <p>Lookup Flow: <pre><code>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</code></pre></p>"},{"location":"v2/database/models/influence/#common-queries","title":"Common Queries","text":""},{"location":"v2/database/models/influence/#create-campaign","title":"Create Campaign","text":"<pre><code>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</code></pre>"},{"location":"v2/database/models/influence/#queue-campaign-email-smtp","title":"Queue Campaign Email (SMTP)","text":"<pre><code>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</code></pre>"},{"location":"v2/database/models/influence/#submit-response","title":"Submit Response","text":"<pre><code>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</code></pre>"},{"location":"v2/database/models/influence/#upvote-response","title":"Upvote Response","text":"<pre><code>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</code></pre>"},{"location":"v2/database/models/influence/#related-documentation","title":"Related Documentation","text":"<ul> <li>Schema Reference \u2014 Complete field listings</li> <li>Database Overview \u2014 ER diagram</li> <li>API Influence Routes \u2014 REST endpoints</li> <li>Admin Campaigns Page \u2014 Campaign management UI</li> <li>Public Campaign Page \u2014 Public-facing campaign UI</li> </ul>"},{"location":"v2/database/models/map/","title":"Map Models","text":""},{"location":"v2/database/models/map/#overview","title":"Overview","text":"<p>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.</p> <p>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</p> <p>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</p> <p>See Schema Reference for complete field listings.</p>"},{"location":"v2/database/models/map/#building-vs-unit-architecture","title":"Building vs Unit Architecture","text":"<p>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)</p> <p>Address = Unit-level data: - Unit number (apartment #, suite #) - Occupant name/email/phone - Support level (1-4) - Sign request flag - Canvassing notes</p> <p>Relationship: <code>Location ||--o{ Address</code> (one-to-many)</p> <p>Example: <pre><code>// 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</code></pre></p>"},{"location":"v2/database/models/map/#geocoding-providers","title":"Geocoding Providers","text":"<pre><code>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</code></pre> <p>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)</p> <p>Confidence Score: 0-100 (stored in <code>geocodeConfidence</code> field)</p>"},{"location":"v2/database/models/map/#nar-2025-import","title":"NAR 2025 Import","text":"<p>NAR = National Address Register (Canadian electoral data)</p> <p>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)</p> <p>New Location Fields: - <code>postalCode</code> \u2014 Canadian postal code - <code>province</code> \u2014 Province code (e.g., \"AB\") - <code>federalDistrict</code> \u2014 Federal electoral district - <code>buildingUse</code> \u2014 NAR BU_USE (1=Residential, 2=Partial, 3=Non-Residential, 4=Unknown) - <code>locGuid</code> \u2014 NAR LOC_GUID (unique)</p> <p>New Address Fields: - <code>addrGuid</code> \u2014 NAR ADDR_GUID (unique)</p>"},{"location":"v2/database/models/map/#locationhistory-actions","title":"LocationHistory Actions","text":"<pre><code>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</code></pre> <p>Audit Fields: - <code>field</code> \u2014 Which field changed (e.g., \"latitude\") - <code>oldValue</code> \u2014 Previous value - <code>newValue</code> \u2014 New value - <code>metadata</code> \u2014 JSON with provider, confidence, etc.</p>"},{"location":"v2/database/models/map/#cut-geojson-storage","title":"Cut GeoJSON Storage","text":"<p>Cut stores GeoJSON polygon coordinates:</p> <pre><code>{\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</code></pre> <p>Bounds: Calculated bounding box for quick filtering: <pre><code>{\n \"north\": 53.6,\n \"south\": 53.5,\n \"east\": -113.4,\n \"west\": -113.5\n}\n</code></pre></p>"},{"location":"v2/database/models/map/#shift-status-workflow","title":"Shift Status Workflow","text":"<pre><code>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 --> [*]</code></pre>"},{"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":"<pre><code>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</code></pre>"},{"location":"v2/database/models/map/#find-locations-in-bounding-box","title":"Find Locations in Bounding Box","text":"<pre><code>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</code></pre>"},{"location":"v2/database/models/map/#create-shift-with-cut","title":"Create Shift with Cut","text":"<pre><code>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</code></pre>"},{"location":"v2/database/models/map/#public-shift-signup-creates-temp-user","title":"Public Shift Signup (Creates TEMP User)","text":"<pre><code>// 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</code></pre>"},{"location":"v2/database/models/map/#related-documentation","title":"Related Documentation","text":"<ul> <li>Schema Reference \u2014 Complete field listings</li> <li>Database Overview \u2014 ER diagram</li> <li>API Map Routes \u2014 REST endpoints</li> <li>Admin Locations Page \u2014 Location management UI</li> <li>Admin Cuts Page \u2014 Cut editor UI</li> <li>Public Map Page \u2014 Public map UI</li> </ul>"},{"location":"v2/database/models/media/","title":"Media Models (Drizzle ORM)","text":""},{"location":"v2/database/models/media/#overview","title":"Overview","text":"<p>The Media module uses Drizzle ORM (separate from Prisma) to manage video library, compilations, and job queue.</p> <p>Models (3): - videos \u2014 Video library with metadata - compilations \u2014 Video compilation tracking - jobs \u2014 Job queue with resource management</p> <p>ORM: Drizzle (not Prisma) API: Fastify (port 4100, separate from Express main API) Migration: <code>npx drizzle-kit push</code> (no migration files)</p> <p>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</p> <p>See Schema Reference for complete field listings.</p>"},{"location":"v2/database/models/media/#directory-types","title":"Directory Types","text":"<pre><code>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</code></pre> <p>Usage: - Efficient filtering (indexed) - Replaces LIKE patterns (e.g., <code>path LIKE '%/studios/%'</code>)</p>"},{"location":"v2/database/models/media/#video-metadata-ffprobe","title":"Video Metadata (FFprobe)","text":"<p>Extracted Fields: - <code>durationSeconds</code> \u2014 Video duration in seconds - <code>width</code> / <code>height</code> \u2014 Video dimensions (pixels) - <code>orientation</code> \u2014 portrait, landscape, square - <code>quality</code> \u2014 1080p, 720p, 480p, etc. - <code>hasAudio</code> \u2014 Audio track present flag</p> <p>Extraction Service: <code>api/src/modules/media/services/ffprobe.service.ts</code> Timeout: 30 seconds for metadata extraction Validation: Decodes 5 frames with 60s timeout</p>"},{"location":"v2/database/models/media/#job-queue","title":"Job Queue","text":"<p>Resource Categories: <pre><code>export type ResourceCategory = 'gpu_ai' | 'gpu_encode' | 'cpu';\n</code></pre></p> <p>Job Status: <pre><code>export type JobStatus = 'pending' | 'queued' | 'running' | 'completed' | 'failed' | 'cancelled';\n</code></pre></p> <p>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</p> <p>Queue Processing: - Ordered by: status (pending first), priority (lower = higher), createdAt (FIFO) - Uses composite index: <code>[status, priority, createdAt]</code></p>"},{"location":"v2/database/models/media/#video-upload-flow","title":"Video Upload Flow","text":"<pre><code>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 }</code></pre> <p>Volume Mount: <code>/media/local/inbox:rw</code> (read-write), library remains <code>:ro</code></p>"},{"location":"v2/database/models/media/#drizzle-schema-example","title":"Drizzle Schema Example","text":"<pre><code>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</code></pre>"},{"location":"v2/database/models/media/#related-documentation","title":"Related Documentation","text":"<ul> <li>Schema Reference \u2014 Complete field listings</li> <li>Migration Workflow \u2014 Drizzle Kit push</li> <li>API Media Routes \u2014 REST endpoints</li> <li>Admin Media Library \u2014 Video management UI</li> <li>Public Media Gallery \u2014 Public video gallery</li> </ul>"},{"location":"v2/database/models/pages/","title":"Landing Page Models","text":""},{"location":"v2/database/models/pages/#overview","title":"Overview","text":"<p>The Landing Page module provides a WYSIWYG page builder with GrapesJS editor integration, reusable block library, and MkDocs export functionality.</p> <p>Models (2): - LandingPage \u2014 GrapesJS editor output with MkDocs export - PageBlock \u2014 Reusable block library</p> <p>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</p> <p>See Schema Reference for complete field listings.</p>"},{"location":"v2/database/models/pages/#editor-modes","title":"Editor Modes","text":"<pre><code>enum EditorMode {\n VISUAL // GrapesJS visual editor (default)\n CODE // Raw HTML/CSS code editor\n}\n</code></pre>"},{"location":"v2/database/models/pages/#mkdocs-export-modes","title":"MkDocs Export Modes","text":"<pre><code>enum MkdocsExportMode {\n THEMED // Extends main.html, content block only (default)\n STANDALONE // Full HTML document, no Jinja2 inheritance\n}\n</code></pre> <p>THEMED Mode: <pre><code>{% extends \"main.html\" %}\n{% block content %}\n <div class=\"landing-page\">\n <!-- Page HTML here -->\n </div>\n{% endblock %}\n</code></pre></p> <p>STANDALONE Mode: <pre><code><!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</code></pre></p>"},{"location":"v2/database/models/pages/#page-blocks-6-default","title":"Page Blocks (6 default)","text":"<p>1. Hero Section (Headers) - Schema: title, subtitle, backgroundImage, ctaText, ctaUrl - Defaults: \"Welcome to Our Campaign\", \"Get Involved\"</p> <p>2. Text Block (Content) - Schema: heading, body - Defaults: \"About Us\", \"Tell your story here...\"</p> <p>3. Features Grid (Content) - Schema: features[] (title, description, icon) - Defaults: 3 features (Community Action, Advocacy, Volunteer)</p> <p>4. Call to Action (Actions) - Schema: heading, description, buttonText, buttonUrl - Defaults: \"Ready to Take Action?\", \"Join Now\"</p> <p>5. Testimonials (Content) - Schema: quotes[] (text, author, role) - Defaults: 2 quotes</p> <p>6. Contact Form (Actions) - Schema: heading, fields[] (name, type, required) - Defaults: Name, Email, Message fields</p>"},{"location":"v2/database/models/pages/#grapesjs-json-format","title":"GrapesJS JSON Format","text":"<p>blocks Field: <pre><code>{\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</code></pre></p>"},{"location":"v2/database/models/pages/#related-documentation","title":"Related Documentation","text":"<ul> <li>Schema Reference \u2014 Complete field listings</li> <li>Seeding \u2014 Default page blocks</li> <li>API Pages Routes \u2014 REST endpoints</li> <li>Admin Landing Pages \u2014 Page list UI</li> <li>Admin Page Editor \u2014 GrapesJS editor UI</li> <li>Public Landing Page \u2014 Public page renderer</li> </ul>"},{"location":"v2/database/models/settings/","title":"Settings Models","text":""},{"location":"v2/database/models/settings/#overview","title":"Overview","text":"<p>The Settings module provides two singleton configuration models for global site settings and map-specific settings.</p> <p>Models (2): - SiteSettings \u2014 Org branding + theme + SMTP + feature toggles - MapSettings \u2014 Map center/zoom + walk sheet config</p> <p>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)</p> <p>See Schema Reference for complete field listings.</p>"},{"location":"v2/database/models/settings/#sitesettings-singleton","title":"SiteSettings (Singleton)","text":"<p>ID: Always <code>\"default\"</code> (enforced by seed + UI)</p> <p>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</p> <p>SMTP Hierarchy: - If SiteSettings.smtpHost is set \u2192 use SiteSettings SMTP - Else \u2192 fallback to env vars (SMTP_HOST, SMTP_PORT, etc.)</p>"},{"location":"v2/database/models/settings/#mapsettings-singleton","title":"MapSettings (Singleton)","text":"<p>ID: Always <code>\"default\"</code> (enforced by seed + UI)</p> <p>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)</p> <p>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=...)</p>"},{"location":"v2/database/models/settings/#related-documentation","title":"Related Documentation","text":"<ul> <li>Schema Reference \u2014 Complete field listings</li> <li>Seeding \u2014 Default settings</li> <li>API Settings Routes \u2014 REST endpoints</li> <li>Admin Settings Page \u2014 Settings UI</li> <li>Admin Map Settings Page \u2014 Map settings UI</li> </ul>"},{"location":"v2/deployment/","title":"Deployment Overview","text":"<p>This section covers deploying Changemaker Lite V2 to production, including Docker orchestration, environment configuration, SSL/TLS setup, monitoring, backups, and scaling strategies.</p>"},{"location":"v2/deployment/#deployment-guide","title":"Deployment Guide","text":""},{"location":"v2/deployment/#docker-compose","title":"Docker Compose","text":"<p>Complete Docker orchestration for all services:</p> <ul> <li>20+ containers</li> <li>Service dependencies</li> <li>Health checks</li> <li>Restart policies</li> <li>Network configuration</li> <li>Volume management</li> </ul>"},{"location":"v2/deployment/#environment-variables","title":"Environment Variables","text":"<p>Comprehensive environment configuration:</p> <ul> <li>100+ environment variables</li> <li>Required vs optional</li> <li>Security considerations</li> <li>Service-specific config</li> <li>Feature flags</li> </ul>"},{"location":"v2/deployment/#nginx-configuration","title":"Nginx Configuration","text":"<p>Reverse proxy and routing:</p> <ul> <li>Subdomain routing (12+ subdomains)</li> <li>SSL/TLS termination</li> <li>Security headers</li> <li>Proxy settings</li> <li>Static file serving</li> </ul>"},{"location":"v2/deployment/#ssltls-setup","title":"SSL/TLS Setup","text":"<p>HTTPS configuration:</p> <ul> <li>Let's Encrypt integration</li> <li>Certificate management</li> <li>Auto-renewal</li> <li>Security best practices</li> <li>HSTS configuration</li> </ul>"},{"location":"v2/deployment/#tunneling","title":"Tunneling","text":"<p>Public access via tunneling:</p> <ul> <li>Pangolin tunnel setup</li> <li>Newt container deployment</li> <li>Resource configuration</li> <li>Alternative to Cloudflare</li> <li>DNS-free setup</li> </ul>"},{"location":"v2/deployment/#backup-restore","title":"Backup & Restore","text":"<p>Data protection:</p> <ul> <li>PostgreSQL backups</li> <li>Listmonk backups</li> <li>Media file backups</li> <li>S3 upload (optional)</li> <li>Restore procedures</li> <li>Automated schedules</li> </ul>"},{"location":"v2/deployment/#monitoring-stack","title":"Monitoring Stack","text":"<p>Observability and alerting:</p> <ul> <li>Prometheus metrics</li> <li>Grafana dashboards</li> <li>Alertmanager alerts</li> <li>Service health checks</li> <li>Log aggregation</li> </ul>"},{"location":"v2/deployment/#healthchecks","title":"Healthchecks","text":"<p>Container health monitoring:</p> <ul> <li>Docker healthchecks</li> <li>Service-specific checks</li> <li>Restart on failure</li> <li>Dependency management</li> </ul>"},{"location":"v2/deployment/#scaling","title":"Scaling","text":"<p>Horizontal and vertical scaling:</p> <ul> <li>Multi-instance deployment</li> <li>Load balancing</li> <li>Database replication</li> <li>Cache scaling</li> <li>Performance optimization</li> </ul>"},{"location":"v2/deployment/#quick-start","title":"Quick Start","text":""},{"location":"v2/deployment/#initial-deployment","title":"Initial Deployment","text":"<ol> <li> <p>Prepare Server <pre><code># Ubuntu/Debian server with Docker installed\napt update && apt install docker.io docker-compose git\n</code></pre></p> </li> <li> <p>Clone Repository <pre><code>git clone <repo-url> changemaker.lite\ncd changemaker.lite\ngit checkout v2\n</code></pre></p> </li> <li> <p>Configure Environment <pre><code>cp .env.example .env\n# Edit .env with your settings\n</code></pre></p> </li> <li> <p>Start Services <pre><code>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</code></pre></p> </li> <li> <p>Access Application <pre><code>http://server-ip:3000\nLogin: admin@example.com / Admin123!\n</code></pre></p> </li> </ol>"},{"location":"v2/deployment/#production-deployment","title":"Production Deployment","text":"<ol> <li>Configure Tunneling (for public access)</li> <li>Set up Pangolin account</li> <li>Configure tunnel in admin UI</li> <li> <p>Deploy Newt container</p> </li> <li> <p>Enable Monitoring <pre><code>docker compose --profile monitoring up -d\n</code></pre></p> </li> <li> <p>Set Up Backups <pre><code># Configure backup.sh\n./scripts/backup.sh\n\n# Add to crontab\n0 2 * * * /path/to/backup.sh\n</code></pre></p> </li> <li> <p>Secure Installation</p> </li> <li>Change default passwords</li> <li>Enable Redis auth</li> <li>Configure firewall</li> <li>Review security audit</li> </ol>"},{"location":"v2/deployment/#architecture-overview","title":"Architecture Overview","text":""},{"location":"v2/deployment/#service-topology","title":"Service Topology","text":"<pre><code>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</code></pre>"},{"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":"<ul> <li> Change default admin password</li> <li> Set strong PostgreSQL password</li> <li> Set strong Redis password</li> <li> Generate unique JWT secrets</li> <li> Generate unique encryption key</li> <li> Enable Redis authentication</li> <li> Configure firewall rules</li> <li> Review security audit findings</li> </ul>"},{"location":"v2/deployment/#environment","title":"Environment","text":"<ul> <li> Set production NODE_ENV</li> <li> Configure SMTP settings</li> <li> Set up geocoding API keys</li> <li> Configure Listmonk (if enabled)</li> <li> Set media storage paths</li> <li> Configure backup destinations</li> </ul>"},{"location":"v2/deployment/#services","title":"Services","text":"<ul> <li> Start core services</li> <li> Run database migrations</li> <li> Seed initial data</li> <li> Test admin login</li> <li> Verify API connectivity</li> <li> Check service health</li> </ul>"},{"location":"v2/deployment/#monitoring","title":"Monitoring","text":"<ul> <li> Enable monitoring stack</li> <li> Configure Grafana dashboards</li> <li> Set up Alertmanager</li> <li> Test alert notifications</li> <li> Review metrics collection</li> </ul>"},{"location":"v2/deployment/#backups","title":"Backups","text":"<ul> <li> Configure backup script</li> <li> Test backup/restore</li> <li> Set up automated schedule</li> <li> Configure S3 (optional)</li> <li> Document restore procedure</li> </ul>"},{"location":"v2/deployment/#public-access","title":"Public Access","text":"<ul> <li> Configure tunnel (Pangolin/Cloudflare)</li> <li> Test public URLs</li> <li> Verify SSL/TLS</li> <li> Check subdomain routing</li> <li> Test from external network</li> </ul>"},{"location":"v2/deployment/#maintenance","title":"Maintenance","text":""},{"location":"v2/deployment/#regular-tasks","title":"Regular Tasks","text":"<p>Daily: - Monitor service health - Review error logs - Check disk space</p> <p>Weekly: - Review backup success - Check queue depths - Update dependencies (if needed)</p> <p>Monthly: - Security updates - Database optimization - Log rotation - Certificate renewal check</p>"},{"location":"v2/deployment/#updates","title":"Updates","text":"<ol> <li> <p>Pull Latest Code <pre><code>git pull origin v2\n</code></pre></p> </li> <li> <p>Rebuild Containers <pre><code>docker compose build\ndocker compose up -d\n</code></pre></p> </li> <li> <p>Run Migrations <pre><code>docker compose exec api npx prisma migrate deploy\n</code></pre></p> </li> <li> <p>Verify Services <pre><code>docker compose ps\ncurl http://localhost:4000/health\n</code></pre></p> </li> </ol>"},{"location":"v2/deployment/#troubleshooting","title":"Troubleshooting","text":"<p>Common deployment issues:</p> <ul> <li>Container fails to start - Check logs, environment variables</li> <li>Database connection error - Verify PostgreSQL password, port</li> <li>Redis connection error - Check Redis password, authentication</li> <li>Nginx routing issues - Review nginx config, test upstream services</li> <li>Tunnel connection fails - Verify Pangolin credentials, Newt config</li> <li>SSL certificate errors - Check Let's Encrypt rate limits, renewal</li> </ul> <p>See Troubleshooting Guide for detailed solutions.</p>"},{"location":"v2/deployment/#resource-requirements","title":"Resource Requirements","text":""},{"location":"v2/deployment/#minimum","title":"Minimum","text":"<ul> <li>CPU: 2 cores</li> <li>RAM: 4 GB</li> <li>Disk: 20 GB SSD</li> <li>Network: 10 Mbps</li> </ul>"},{"location":"v2/deployment/#recommended","title":"Recommended","text":"<ul> <li>CPU: 4 cores</li> <li>RAM: 8 GB</li> <li>Disk: 50 GB SSD</li> <li>Network: 100 Mbps</li> </ul>"},{"location":"v2/deployment/#high-load","title":"High Load","text":"<ul> <li>CPU: 8+ cores</li> <li>RAM: 16+ GB</li> <li>Disk: 100+ GB SSD</li> <li>Network: 1 Gbps</li> </ul>"},{"location":"v2/deployment/#related-documentation","title":"Related Documentation","text":"<ul> <li>Docker Compose</li> <li>Environment Variables</li> <li>Nginx Configuration</li> <li>SSL/TLS Setup</li> <li>Tunneling</li> <li>Backup & Restore</li> <li>Monitoring Stack</li> <li>Healthchecks</li> <li>Scaling</li> <li>Troubleshooting</li> </ul>"},{"location":"v2/deployment/backup-restore/","title":"Backup & Restore Procedures","text":""},{"location":"v2/deployment/backup-restore/#overview","title":"Overview","text":"<p>The <code>scripts/backup.sh</code> script provides automated backups of: - V2 PostgreSQL database (pg_dump) - Listmonk PostgreSQL database (pg_dump) - Uploads directory (tar.gz) - Backup manifest (SHA256 checksums)</p> <p>Optional S3 upload for offsite storage.</p>"},{"location":"v2/deployment/backup-restore/#quick-start","title":"Quick Start","text":""},{"location":"v2/deployment/backup-restore/#manual-backup","title":"Manual Backup","text":"<pre><code># 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</code></pre> <p>Output: <code>backups/changemaker-v2-backup-YYYYMMDD_HHMMSS.tar.gz</code></p>"},{"location":"v2/deployment/backup-restore/#automated-backups-cron","title":"Automated Backups (Cron)","text":"<pre><code># 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</code></pre>"},{"location":"v2/deployment/backup-restore/#backup-script-walkthrough","title":"Backup Script Walkthrough","text":""},{"location":"v2/deployment/backup-restore/#configuration","title":"Configuration","text":"<p>Location: <code>scripts/backup.sh</code></p> <p>Variables: <pre><code>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</code></pre></p> <p>Environment: Loads <code>.env</code> automatically (safe parsing handles quotes/special chars).</p>"},{"location":"v2/deployment/backup-restore/#backup-steps","title":"Backup Steps","text":""},{"location":"v2/deployment/backup-restore/#1-v2-postgresql-dump","title":"1. V2 PostgreSQL Dump","text":"<pre><code>docker exec changemaker-v2-postgres \\\n pg_dump -U changemaker -d changemaker_v2 --no-owner --no-acl \\\n | gzip > v2-postgres.sql.gz\n</code></pre> <p>Options: - <code>--no-owner</code>: Skip ownership commands (easier restore) - <code>--no-acl</code>: Skip permissions (easier restore) - <code>gzip</code>: Compress (70-80% reduction)</p> <p>Size estimate: 100MB-2GB (depends on data volume).</p>"},{"location":"v2/deployment/backup-restore/#2-listmonk-postgresql-dump","title":"2. Listmonk PostgreSQL Dump","text":"<pre><code>docker exec listmonk-db \\\n pg_dump -U listmonk -d listmonk --no-owner --no-acl \\\n | gzip > listmonk-postgres.sql.gz\n</code></pre> <p>Optional: Skipped if Listmonk container not running.</p> <p>Size estimate: 10MB-500MB (depends on subscriber count + campaigns).</p>"},{"location":"v2/deployment/backup-restore/#3-uploads-archive","title":"3. Uploads Archive","text":"<pre><code>tar -czf uploads.tar.gz -C assets/ uploads/\n</code></pre> <p>Includes: - Campaign email attachments - Response wall images - Listmonk campaign uploads</p> <p>Size estimate: 100MB-10GB (depends on file uploads).</p>"},{"location":"v2/deployment/backup-restore/#4-backup-manifest","title":"4. Backup Manifest","text":"<p>Format: JSON with file list + SHA256 checksums.</p> <pre><code>{\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</code></pre> <p>Purpose: Verify backup integrity + metadata.</p>"},{"location":"v2/deployment/backup-restore/#final-archive","title":"Final Archive","text":"<p>Creates single tar.gz: <pre><code>tar -czf changemaker-v2-backup-20260213_140530.tar.gz \\\n changemaker-v2-backup-20260213_140530/\n</code></pre></p> <p>Removes temp directory after archiving.</p>"},{"location":"v2/deployment/backup-restore/#optional-s3-upload","title":"Optional S3 Upload","text":"<p>Requires: - AWS CLI installed (<code>apt install awscli</code>) - Credentials configured (<code>aws configure</code>) - <code>S3_BUCKET</code> env var set</p> <p>Command: <pre><code>aws s3 cp changemaker-v2-backup-20260213_140530.tar.gz \\\n s3://${S3_BUCKET}/${S3_PREFIX}/\n</code></pre></p> <p>S3 prefix: <code>${S3_PREFIX:-changemaker-backups}</code> (customizable).</p>"},{"location":"v2/deployment/backup-restore/#retention-cleanup","title":"Retention Cleanup","text":"<p>Deletes backups older than <code>RETENTION_DAYS</code>: <pre><code>find backups/ -name \"changemaker-v2-backup-*.tar.gz\" -mtime +30 -delete\n</code></pre></p> <p>Local only (S3 has its own lifecycle policies).</p>"},{"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":"<pre><code># 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</code></pre>"},{"location":"v2/deployment/backup-restore/#2-restore-v2-database","title":"2. Restore V2 Database","text":"<pre><code># 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</code></pre>"},{"location":"v2/deployment/backup-restore/#3-restore-listmonk-database","title":"3. Restore Listmonk Database","text":"<pre><code># 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</code></pre>"},{"location":"v2/deployment/backup-restore/#4-restore-uploads","title":"4. Restore Uploads","text":"<pre><code># Extract uploads\ntar -xzf uploads.tar.gz -C ./assets/\n\n# Verify\nls -lh assets/uploads/\n</code></pre>"},{"location":"v2/deployment/backup-restore/#5-start-services","title":"5. Start Services","text":"<pre><code># 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</code></pre>"},{"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":"<pre><code># 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</code></pre>"},{"location":"v2/deployment/backup-restore/#restore-specific-files","title":"Restore Specific Files","text":"<pre><code># 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</code></pre>"},{"location":"v2/deployment/backup-restore/#backup-verification","title":"Backup Verification","text":""},{"location":"v2/deployment/backup-restore/#integrity-check","title":"Integrity Check","text":"<pre><code># 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</code></pre> <p>Expected output: <code>OK</code> for each file.</p>"},{"location":"v2/deployment/backup-restore/#test-restore-dry-run","title":"Test Restore (Dry Run)","text":"<p>Best practice: Periodically test restores.</p> <pre><code># 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</code></pre>"},{"location":"v2/deployment/backup-restore/#s3-configuration","title":"S3 Configuration","text":""},{"location":"v2/deployment/backup-restore/#setup-aws-cli","title":"Setup AWS CLI","text":"<pre><code># 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</code></pre>"},{"location":"v2/deployment/backup-restore/#create-s3-bucket","title":"Create S3 Bucket","text":"<pre><code># 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</code></pre>"},{"location":"v2/deployment/backup-restore/#environment-variables","title":"Environment Variables","text":"<pre><code># 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</code></pre>"},{"location":"v2/deployment/backup-restore/#retention-policies","title":"Retention Policies","text":""},{"location":"v2/deployment/backup-restore/#recommended-strategy","title":"Recommended Strategy","text":"<p>Daily backups: Keep 7 days Weekly backups: Keep 4 weeks Monthly backups: Keep 12 months </p> <p>Implementation (via cron): <pre><code># 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</code></pre></p>"},{"location":"v2/deployment/backup-restore/#s3-lifecycle","title":"S3 Lifecycle","text":"<p>Glacier transition (archive old backups): <pre><code>{\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</code></pre></p> <p>Apply: <pre><code>aws s3api put-bucket-lifecycle-configuration \\\n --bucket changemaker-backups \\\n --lifecycle-configuration file://lifecycle.json\n</code></pre></p>"},{"location":"v2/deployment/backup-restore/#disaster-recovery","title":"Disaster Recovery","text":""},{"location":"v2/deployment/backup-restore/#complete-server-loss","title":"Complete Server Loss","text":"<p>Scenario: Server crashes, all data lost.</p> <p>Recovery Steps:</p> <ol> <li>Provision new server (same OS, Docker installed)</li> <li>Clone repository: <pre><code>git clone <repo> changemaker.lite\ncd changemaker.lite\ngit checkout v2\n</code></pre></li> <li>Restore .env file (from secure backup location)</li> <li>Download latest backup from S3: <pre><code>aws s3 cp s3://changemaker-backups/changemaker-backups/latest.tar.gz ./\n</code></pre></li> <li>Extract + restore (see Full Restore above)</li> <li>Start services: <pre><code>docker compose up -d\n</code></pre></li> <li>Verify: <pre><code>docker compose ps\ncurl http://localhost:4000/api/health\n</code></pre></li> </ol> <p>RTO (Recovery Time Objective): 30-60 minutes RPO (Recovery Point Objective): Last backup (e.g., 24h for daily backups)</p>"},{"location":"v2/deployment/backup-restore/#database-corruption","title":"Database Corruption","text":"<p>Scenario: PostgreSQL data corruption detected.</p> <p>Recovery: <pre><code># 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</code></pre></p>"},{"location":"v2/deployment/backup-restore/#monitoring-backup-success","title":"Monitoring Backup Success","text":""},{"location":"v2/deployment/backup-restore/#log-files","title":"Log Files","text":"<p>Cron output: <pre><code># 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</code></pre></p>"},{"location":"v2/deployment/backup-restore/#prometheus-metrics-custom","title":"Prometheus Metrics (Custom)","text":"<p>Add to <code>api/src/utils/metrics.ts</code>: <pre><code>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</code></pre></p> <p>Alert rule: <pre><code>- 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</code></pre></p>"},{"location":"v2/deployment/backup-restore/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/deployment/backup-restore/#pg_dump-permission-denied","title":"pg_dump: permission denied","text":"<p>Symptoms: Backup fails with \"permission denied for database\"</p> <p>Cause: PostgreSQL user lacks dump privileges.</p> <p>Solution: <pre><code># 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</code></pre></p>"},{"location":"v2/deployment/backup-restore/#s3-upload-fails-invalidaccesskeyid","title":"S3 upload fails: InvalidAccessKeyId","text":"<p>Symptoms: AWS CLI authentication error</p> <p>Solution: <pre><code># Verify credentials\naws sts get-caller-identity\n\n# Reconfigure\naws configure\n\n# Test S3 access\naws s3 ls s3://changemaker-backups/\n</code></pre></p>"},{"location":"v2/deployment/backup-restore/#restore-fails-relation-already-exists","title":"Restore fails: relation already exists","text":"<p>Symptoms: <code>psql: ERROR: relation \"users\" already exists</code></p> <p>Cause: Restoring to non-empty database.</p> <p>Solution: <pre><code># 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</code></pre></p>"},{"location":"v2/deployment/backup-restore/#best-practices","title":"Best Practices","text":""},{"location":"v2/deployment/backup-restore/#security","title":"Security","text":"<ul> <li> Encrypt backups at rest (S3 encryption enabled)</li> <li> Restrict .env file access (<code>chmod 600 .env</code>)</li> <li> Store S3 credentials securely (not in .env committed to Git)</li> <li> Test restore procedures monthly</li> <li> Document recovery procedures (this guide!)</li> </ul>"},{"location":"v2/deployment/backup-restore/#automation","title":"Automation","text":"<ul> <li> Schedule daily backups via cron</li> <li> Monitor backup success (log files + metrics)</li> <li> Alert on backup failures</li> <li> Rotate local backups (retention policy)</li> <li> Offsite storage (S3 or alternative)</li> </ul>"},{"location":"v2/deployment/backup-restore/#documentation","title":"Documentation","text":"<ul> <li> Document .env restoration procedure</li> <li> Keep list of critical files to backup</li> <li> Document service dependencies</li> <li> Test disaster recovery plan annually</li> </ul>"},{"location":"v2/deployment/backup-restore/#related-documentation","title":"Related Documentation","text":"<ul> <li>Docker Compose \u2014 Service orchestration</li> <li>Environment Variables \u2014 .env restoration</li> <li>Monitoring Stack \u2014 Backup monitoring metrics</li> </ul>"},{"location":"v2/deployment/docker-compose/","title":"Docker Compose Orchestration","text":""},{"location":"v2/deployment/docker-compose/#overview","title":"Overview","text":"<p>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.</p> <p>Key Benefits:</p> <ul> <li>Single Configuration File: All services defined in <code>docker-compose.yml</code></li> <li>Automatic Networking: All containers communicate via a shared bridge network</li> <li>Health Checks: 7 critical services have automated health monitoring</li> <li>Volume Persistence: Database, uploads, and configuration data persisted across restarts</li> <li>Profile Support: Optional monitoring stack behind <code>--profile monitoring</code> flag</li> <li>Container Dependencies: Services start in correct order via <code>depends_on</code> relationships</li> </ul> <p>Architecture:</p> <p>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).</p>"},{"location":"v2/deployment/docker-compose/#service-architecture","title":"Service Architecture","text":"<pre><code>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</code></pre>"},{"location":"v2/deployment/docker-compose/#core-services","title":"Core Services","text":""},{"location":"v2/deployment/docker-compose/#v2-postgres","title":"v2-postgres","text":"<p>Purpose: PostgreSQL 16 database for V2 platform (main app + NocoDB metadata)</p> <p>Configuration: <pre><code>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</code></pre></p> <p>Key Features: - Alpine image for minimal footprint - <code>init-nocodb-db.sh</code> creates separate <code>nocodb_meta</code> database on first startup - Health check uses <code>pg_isready</code> for fast readiness detection - Port bound to <code>127.0.0.1</code> to prevent external access</p> <p>Volumes: - <code>v2-postgres-data</code>: Persistent PostgreSQL data directory</p> <p>Dependencies: None (starts first)</p>"},{"location":"v2/deployment/docker-compose/#redis","title":"redis","text":"<p>Purpose: Shared Redis instance for sessions, BullMQ job queues, rate limiting, and geocoding cache</p> <p>Configuration: <pre><code>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</code></pre></p> <p>Key Features: - Authentication required: <code>--requirepass</code> flag enforces password on all connections - AOF persistence: <code>--appendonly yes</code> writes every command to disk - Memory limits: 512MB max with LRU eviction policy - Resource constraints: Prevents Redis from consuming excessive host resources</p> <p>Volumes: - <code>redis-data</code>: Persistent AOF log and RDB snapshots</p> <p>Security Note: As of Security Audit 2025-02-11, Redis authentication is REQUIRED in production. Set a strong <code>REDIS_PASSWORD</code> in <code>.env</code>.</p>"},{"location":"v2/deployment/docker-compose/#api","title":"api","text":"<p>Purpose: Unified Express.js API (TypeScript, Prisma ORM)</p> <p>Configuration: <pre><code>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</code></pre></p> <p>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 <code>/api/health</code> endpoint with 30s startup grace period - Exposes Listmonk proxy on port 9002 (OAuth integration)</p> <p>Volumes: - <code>./api:/app</code>: Live code reloading - <code>/app/node_modules</code>: Prevents host node_modules conflicts - <code>./assets/uploads:/app/uploads</code>: Shared upload directory - <code>./mkdocs:/mkdocs:rw</code>: MkDocs export target - <code>./data:/data:ro</code>: NAR import data (read-only) - <code>/var/run/docker.sock</code>: Docker API access</p> <p>Environment Variables: See Environment Variables for complete reference.</p>"},{"location":"v2/deployment/docker-compose/#media-api","title":"media-api","text":"<p>Purpose: Fastify microservice for video library management (Drizzle ORM)</p> <p>Configuration: <pre><code>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</code></pre></p> <p>Key Features: - Separate Dockerfile (<code>Dockerfile.media</code>) 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)</p> <p>Volumes: - <code>${MEDIA_ROOT}:/media:ro</code>: Read-only media library - <code>${MEDIA_ROOT}/local/inbox:/media/local/inbox:rw</code>: RW mount required for video uploads</p> <p>Important: The inbox directory must have <code>:rw</code> flag; main library stays <code>:ro</code> for security.</p>"},{"location":"v2/deployment/docker-compose/#admin","title":"admin","text":"<p>Purpose: React admin GUI (Vite dev server in development, Nginx in production)</p> <p>Configuration: <pre><code>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</code></pre></p> <p>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</p> <p>Environment Variables: - <code>VITE_API_URL</code>: Points to API container (not localhost) - <code>VITE_MEDIA_API_URL</code>: Points to media-api container - <code>VITE_MKDOCS_URL</code>: Points to MkDocs container for iframe embed</p> <p>Production Build: Swap <code>target: development</code> to <code>target: production</code> and serve static files via Nginx.</p>"},{"location":"v2/deployment/docker-compose/#nginx","title":"nginx","text":"<p>Purpose: Reverse proxy with subdomain routing, SSL termination, and iframe embedding support</p> <p>Configuration: <pre><code>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</code></pre></p> <p>Key Features: - Subdomain routing: <code>api.cmlite.org</code>, <code>app.cmlite.org</code>, <code>db.cmlite.org</code>, etc. - Embed proxy ports: 888x ports strip <code>X-Frame-Options</code> for iframe embedding - Health check: Validates both HTTP server + cron daemon (for cert renewal) - Read-only configs: Prevents accidental modification</p> <p>Configuration Files: - <code>nginx.conf</code>: Global settings, gzip, security headers - <code>conf.d/default.conf</code>: Localhost fallback + path-based routing - <code>conf.d/api.conf</code>: API subdomain routing (media endpoints must come before <code>/api/</code>) - <code>conf.d/services.conf</code>: All supporting services + CSP headers</p> <p>See Nginx Configuration for complete routing details.</p>"},{"location":"v2/deployment/docker-compose/#nocodb-v2","title":"nocodb-v2","text":"<p>Purpose: Read-only database browser for V2 schema</p> <p>Configuration: <pre><code>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</code></pre></p> <p>Key Features: - Uses separate <code>nocodb_meta</code> database (auto-created by <code>init-nocodb-db.sh</code>) - Health check via NocoDB API endpoint - Read-only access recommended (grant SELECT only in production)</p> <p>Volumes: - <code>nocodb-v2-data</code>: NocoDB's internal file storage</p> <p>Access: http://localhost:8091 or http://db.cmlite.org (via subdomain routing)</p>"},{"location":"v2/deployment/docker-compose/#supporting-services","title":"Supporting Services","text":""},{"location":"v2/deployment/docker-compose/#listmonk-app","title":"listmonk-app","text":"<p>Purpose: Email marketing platform for newsletters (V2 syncs subscribers via REST API)</p> <p>Configuration: <pre><code>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</code></pre></p> <p>Key Features: - Idempotent init: <code>--install --idempotent</code> runs migrations on every start (safe) - Auto-upgrade: <code>--upgrade --yes</code> applies schema upgrades - Shared uploads: Uses same upload directory as main API</p> <p>Database: Uses separate PostgreSQL 17 instance (<code>listmonk-db</code>)</p> <p>API Integration: V2 API syncs participants/locations to Listmonk lists via REST API (opt-in via <code>LISTMONK_SYNC_ENABLED=true</code>)</p>"},{"location":"v2/deployment/docker-compose/#listmonk-db","title":"listmonk-db","text":"<p>Purpose: PostgreSQL 17 database for Listmonk</p> <p>Configuration: <pre><code>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</code></pre></p> <p>Key Features: - Separate PostgreSQL instance (not shared with V2 database) - Port bound to <code>127.0.0.1</code> for security</p> <p>Volumes: - <code>listmonk-data</code>: Persistent Listmonk database</p>"},{"location":"v2/deployment/docker-compose/#listmonk-init","title":"listmonk-init","text":"<p>Purpose: One-shot container to create Listmonk API user for V2 integration</p> <p>Configuration: <pre><code>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</code></pre></p> <p>Key Features: - Idempotent: Safe to run multiple times (upserts API user) - Auto-configuration: Also configures SMTP providers (MailHog + production) - Exit on completion: <code>restart: \"no\"</code> prevents restart after success</p> <p>Important: Listmonk API users store tokens as plaintext (not bcrypt), so direct SQL upsert works.</p>"},{"location":"v2/deployment/docker-compose/#gitea-app","title":"gitea-app","text":"<p>Purpose: Self-hosted Git repository hosting</p> <p>Configuration: <pre><code>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</code></pre></p> <p>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: <code>X_FRAME_OPTIONS</code> disabled for admin iframe</p> <p>Health Check: Uses <code>curl</code> (Debian-based image) not <code>wget</code></p> <p>Volumes: - <code>gitea-data</code>: Git repositories + attachments</p>"},{"location":"v2/deployment/docker-compose/#n8n","title":"n8n","text":"<p>Purpose: Workflow automation platform</p> <p>Configuration: <pre><code>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</code></pre></p> <p>Key Features: - HTTPS required: <code>N8N_PROTOCOL=https</code> for webhook security - User management: Creates default admin user on first start - File access: <code>/files</code> directory for workflow file operations</p> <p>Health Check: <code>/healthz</code> endpoint (Alpine image uses <code>wget</code>)</p> <p>Volumes: - <code>n8n-data</code>: Workflow definitions + credentials - <code>./local-files:/files</code>: Shared file directory for workflows</p>"},{"location":"v2/deployment/docker-compose/#mkdocs","title":"mkdocs","text":"<p>Purpose: Live documentation preview server (Material theme)</p> <p>Configuration: <pre><code>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</code></pre></p> <p>Key Features: - Live reloading: <code>--livereload</code> watches for file changes - User mapping: Runs as host user to prevent permission issues - Port 4003: Changed from 4000 (conflicted with API in V1)</p> <p>Volumes: - <code>./mkdocs:/docs:rw</code>: Documentation source (writable for MkDocs export) - <code>./assets/images:/docs/assets/images:rw</code>: Shared image directory</p> <p>Access: http://localhost:4003 or http://docs.cmlite.org (via subdomain routing)</p>"},{"location":"v2/deployment/docker-compose/#code-server","title":"code-server","text":"<p>Purpose: VS Code in the browser for documentation editing</p> <p>Configuration: <pre><code>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</code></pre></p> <p>Key Features: - User mapping: Runs as host user (prevents permission conflicts) - Project mount: Entire repository mounted at <code>/home/coder/project</code> - Persistent config: <code>.config</code> and <code>.local</code> directories preserved</p> <p>Access: http://localhost:8888 or http://code.cmlite.org (via subdomain routing)</p>"},{"location":"v2/deployment/docker-compose/#mailhog","title":"mailhog","text":"<p>Purpose: Email capture for development/testing</p> <p>Configuration: <pre><code>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</code></pre></p> <p>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</p> <p>Usage: Set <code>EMAIL_TEST_MODE=true</code> in <code>.env</code> to route all emails to MailHog</p> <p>Access: http://localhost:8025 or http://mail.cmlite.org (via subdomain routing)</p>"},{"location":"v2/deployment/docker-compose/#mini-qr","title":"mini-qr","text":"<p>Purpose: QR code generation service (used by walk sheets + cut exports)</p> <p>Configuration: <pre><code>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</code></pre></p> <p>Key Features: - Stateless: No volumes or persistent data - Lightweight: Alpine-based image</p> <p>API Integration: V2 API has dedicated <code>/api/qr</code> routes for direct PNG generation; mini-qr used for admin iframe</p> <p>Access: http://localhost:8089 or http://qr.cmlite.org (via subdomain routing)</p>"},{"location":"v2/deployment/docker-compose/#homepage","title":"homepage","text":"<p>Purpose: Service dashboard with container status</p> <p>Configuration: <pre><code>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</code></pre></p> <p>Key Features: - Docker socket access: Reads container status - User mapping: Runs as host user with Docker group - Custom dashboard: Configure in <code>configs/homepage/</code></p> <p>Access: http://localhost:3010 or http://home.cmlite.org (via subdomain routing)</p>"},{"location":"v2/deployment/docker-compose/#media-services","title":"Media Services","text":""},{"location":"v2/deployment/docker-compose/#public-media","title":"public-media","text":"<p>Purpose: Public video gallery frontend (React production build)</p> <p>Configuration: <pre><code>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</code></pre></p> <p>Key Features: - Static build: React app served by Nginx (not Vite dev server) - Fast startup: 10s start period (static files load quickly)</p> <p>Access: http://localhost:3100 or <code>/gallery/</code> path via main Nginx</p>"},{"location":"v2/deployment/docker-compose/#tunnel-services","title":"Tunnel Services","text":""},{"location":"v2/deployment/docker-compose/#newt","title":"newt","text":"<p>Purpose: Pangolin tunnel connector (replaces Cloudflare Tunnel)</p> <p>Configuration: <pre><code>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</code></pre></p> <p>Key Features: - Self-hosted: Connects to Pangolin server at <code>api.bnkserve.org</code> - Nginx dependency: All traffic routes through nginx:80 - Auto-reconnect: <code>restart: unless-stopped</code> handles connection drops</p> <p>Setup: Use admin PangolinPage.tsx wizard to configure org \u2192 site \u2192 endpoint \u2192 resource</p> <p>See Tunneling for complete setup guide.</p>"},{"location":"v2/deployment/docker-compose/#monitoring-services-profile-monitoring","title":"Monitoring Services (profile: monitoring)","text":""},{"location":"v2/deployment/docker-compose/#prometheus","title":"prometheus","text":"<p>Purpose: Metrics collection and alerting</p> <p>Configuration: <pre><code>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</code></pre></p> <p>Key Features: - 30-day retention: <code>--storage.tsdb.retention.time=30d</code> - Custom metrics: 12 <code>cm_*</code> metrics from API - Alert rules: <code>alerts.yml</code> defines 12+ alert conditions</p> <p>Scrape Targets: - <code>changemaker-v2-api:4000/api/metrics</code> (10s interval) - <code>redis-exporter:9121</code> (15s interval) - <code>cadvisor:8080</code> (15s interval) - <code>node-exporter:9100</code> (15s interval)</p> <p>Access: http://localhost:9090</p> <p>See Monitoring Stack for complete configuration.</p>"},{"location":"v2/deployment/docker-compose/#grafana","title":"grafana","text":"<p>Purpose: Metrics visualization</p> <p>Configuration: <pre><code>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</code></pre></p> <p>Key Features: - Auto-provisioning: Dashboards from <code>configs/grafana/</code> auto-load on startup - 3 dashboards: Application Overview, API Performance, System Health - Prometheus datasource: Auto-configured via <code>datasources.yml</code></p> <p>Access: http://localhost:3001 (admin/admin default)</p>"},{"location":"v2/deployment/docker-compose/#cadvisor","title":"cadvisor","text":"<p>Purpose: Container resource metrics</p> <p>Configuration: <pre><code>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</code></pre></p> <p>Key Features: - Privileged mode: Required for full system access - Host filesystem: Read-only mounts for metrics collection</p> <p>Access: http://localhost:8080</p>"},{"location":"v2/deployment/docker-compose/#node-exporter","title":"node-exporter","text":"<p>Purpose: Host system metrics (CPU, memory, disk, network)</p> <p>Configuration: <pre><code>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</code></pre></p> <p>Key Features: - Host metrics: CPU, memory, disk, network from host (not container) - Filesystem filters: Excludes virtual filesystems</p> <p>Access: http://localhost:9100/metrics</p>"},{"location":"v2/deployment/docker-compose/#redis-exporter","title":"redis-exporter","text":"<p>Purpose: Redis metrics (memory, commands, connections)</p> <p>Configuration: <pre><code>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</code></pre></p> <p>Key Features: - Authenticated connection: Uses <code>REDIS_PASSWORD</code> env var - Memory metrics: Tracks Redis memory usage</p> <p>Access: http://localhost:9121/metrics</p>"},{"location":"v2/deployment/docker-compose/#alertmanager","title":"alertmanager","text":"<p>Purpose: Alert routing and notification</p> <p>Configuration: <pre><code>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</code></pre></p> <p>Key Features: - Alert grouping: Prevents notification spam - Multiple receivers: Email, Slack, webhook, Gotify</p> <p>Configuration: Edit <code>configs/alertmanager/alertmanager.yml</code></p> <p>Access: http://localhost:9093</p>"},{"location":"v2/deployment/docker-compose/#gotify","title":"gotify","text":"<p>Purpose: Push notification server (optional alert receiver)</p> <p>Configuration: <pre><code>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</code></pre></p> <p>Key Features: - Push notifications: Mobile app support (iOS/Android) - Webhook receiver: Integrates with Alertmanager</p> <p>Access: http://localhost:8889</p>"},{"location":"v2/deployment/docker-compose/#networks-volumes","title":"Networks & Volumes","text":""},{"location":"v2/deployment/docker-compose/#networks","title":"Networks","text":"<p>changemaker-lite: Bridge network shared by all services</p> <pre><code>networks:\n changemaker-lite:\n driver: bridge\n</code></pre> <p>Features: - Automatic DNS: Containers resolve each other by name (e.g., <code>changemaker-v2-api:4000</code>) - Isolation: No external network access unless ports explicitly exposed - Service discovery: Docker's internal DNS server (127.0.0.11)</p>"},{"location":"v2/deployment/docker-compose/#volumes","title":"Volumes","text":"<p>Named volumes (Docker-managed, persistent across container recreation):</p> Volume Purpose Size Estimate <code>v2-postgres-data</code> V2 PostgreSQL database 1-10GB (depends on data) <code>nocodb-v2-data</code> NocoDB metadata + uploads 100MB-1GB <code>redis-data</code> Redis AOF log + RDB snapshots 50-500MB <code>listmonk-data</code> Listmonk PostgreSQL database 100MB-5GB <code>n8n-data</code> n8n workflows + credentials 10-100MB <code>gitea-data</code> Git repositories + attachments 1-50GB <code>mysql-data</code> Gitea MySQL database 100MB-2GB <code>prometheus-data</code> Prometheus TSDB (30 days) 1-5GB <code>grafana-data</code> Grafana dashboards + config 10-100MB <code>alertmanager-data</code> Alert state + silences 1-10MB <code>gotify-data</code> Gotify messages + apps 10-100MB <p>Bind mounts (host directories):</p> Bind Mount Container Path Purpose Permissions <code>./api</code> <code>/app</code> API source code rw <code>./admin</code> <code>/app</code> Admin source code rw <code>./assets/uploads</code> <code>/app/uploads</code>, <code>/listmonk/uploads</code> Shared uploads rw <code>./mkdocs</code> <code>/docs</code>, <code>/mkdocs</code> Documentation source rw <code>./data</code> <code>/data</code> NAR import data ro <code>./nginx/conf.d</code> <code>/etc/nginx/conf.d</code> Nginx config ro <code>./configs/prometheus</code> <code>/etc/prometheus</code> Prometheus config ro <code>./configs/grafana</code> <code>/etc/grafana/provisioning</code> Grafana config ro <code>/var/run/docker.sock</code> <code>/var/run/docker.sock</code> Docker API rw <p>Important: Media library requires special mount: <pre><code>- ${MEDIA_ROOT:-./media}:/media:ro # Main library (read-only)\n- ${MEDIA_ROOT:-./media}/local/inbox:/media/local/inbox:rw # Upload inbox (writable)\n</code></pre></p>"},{"location":"v2/deployment/docker-compose/#starting-services","title":"Starting Services","text":""},{"location":"v2/deployment/docker-compose/#basic-commands","title":"Basic Commands","text":"<p>Start all core services: <pre><code>docker compose up -d\n</code></pre></p> <p>Start with monitoring stack: <pre><code>docker compose --profile monitoring up -d\n</code></pre></p> <p>Start specific service: <pre><code>docker compose up -d api\n</code></pre></p> <p>Start with rebuild: <pre><code>docker compose up -d --build api admin\n</code></pre></p> <p>Stop all services: <pre><code>docker compose down\n</code></pre></p> <p>Stop and remove volumes (\u26a0\ufe0f destroys all data): <pre><code>docker compose down -v\n</code></pre></p>"},{"location":"v2/deployment/docker-compose/#development-workflow","title":"Development Workflow","text":"<p>1. Initial setup (first time only): <pre><code># 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</code></pre></p> <p>2. Daily development: <pre><code># 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</code></pre></p> <p>3. Full stack with monitoring: <pre><code># 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</code></pre></p>"},{"location":"v2/deployment/docker-compose/#log-management","title":"Log Management","text":"<p>View logs: <pre><code># 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</code></pre></p> <p>Log rotation: Configured in <code>docker-compose.yml</code> for Redis + MailHog: <pre><code>logging:\n driver: \"json-file\"\n options:\n max-size: \"5m\"\n max-file: \"2\"\n</code></pre></p>"},{"location":"v2/deployment/docker-compose/#health-checks","title":"Health Checks","text":"<p>Check service health: <pre><code># 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</code></pre></p> <p>Services with health checks: - <code>api</code>: <code>wget http://localhost:4000/api/health</code> (30s start period) - <code>media-api</code>: <code>wget http://127.0.0.1:4100/health</code> (30s start period) - <code>admin</code>: <code>wget http://127.0.0.1:3000/</code> (20s start period) - <code>v2-postgres</code>: <code>pg_isready -U changemaker</code> (5 retries) - <code>redis</code>: <code>redis-cli -a ${REDIS_PASSWORD} ping</code> (5 retries) - <code>gitea-app</code>: <code>curl http://localhost:3000/</code> (30s start period) - <code>n8n</code>: <code>wget http://localhost:5678/healthz</code> (30s start period)</p> <p>Dependency chains (via <code>depends_on</code> with <code>condition: service_healthy</code>): - <code>api</code> waits for <code>v2-postgres</code> + <code>redis</code> - <code>media-api</code> waits for <code>v2-postgres</code> - <code>nocodb-v2</code> waits for <code>v2-postgres</code></p> <p>See Health Checks for detailed configuration.</p>"},{"location":"v2/deployment/docker-compose/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/deployment/docker-compose/#port-conflicts","title":"Port Conflicts","text":"<p>Problem: <code>Error: bind: address already in use</code></p> <p>Solution: <pre><code># 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</code></pre></p> <p>Common conflicts: - Port 3000: Homepage, Grafana, admin (set <code>ADMIN_PORT=3005</code>) - Port 4000: API, MkDocs v1 (set <code>MKDOCS_PORT=4003</code>) - Port 5432: Listmonk DB, system PostgreSQL (bind to 127.0.0.1 in compose file)</p>"},{"location":"v2/deployment/docker-compose/#volume-permission-issues","title":"Volume Permission Issues","text":"<p>Problem: <code>EACCES: permission denied</code> or <code>mkdir: cannot create directory</code></p> <p>Cause: Container user mismatch with host filesystem</p> <p>Solution: <pre><code># 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</code></pre></p> <p>Services using user mapping: - <code>mkdocs</code>: <code>user: \"${USER_ID}:${GROUP_ID}\"</code> - <code>code-server</code>: <code>user: \"${USER_ID}:${GROUP_ID}\"</code> - <code>homepage</code>: <code>PUID=${USER_ID}, PGID=${DOCKER_GROUP_ID}</code></p>"},{"location":"v2/deployment/docker-compose/#network-issues","title":"Network Issues","text":"<p>Problem: Containers can't communicate (e.g., API can't reach Redis)</p> <p>Solution: <pre><code># 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</code></pre></p> <p>DNS resolution: Containers use Docker's internal DNS (127.0.0.11). Reference services by container name: - \u2705 <code>redis-changemaker:6379</code> - \u274c <code>localhost:6379</code> (only works if port exposed to host)</p>"},{"location":"v2/deployment/docker-compose/#database-migration-failures","title":"Database Migration Failures","text":"<p>Problem: <code>prisma migrate deploy</code> fails with \"relation already exists\"</p> <p>Solution: <pre><code># 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</code></pre></p>"},{"location":"v2/deployment/docker-compose/#container-crashes-restart-loops","title":"Container Crashes / Restart Loops","text":"<p>Problem: Container repeatedly restarting</p> <p>Diagnosis: <pre><code># 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</code></pre></p> <p>Common causes: - Missing env vars: Check <code>.env</code> 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</p> <p>Fix: <pre><code># Restart with fresh logs\ndocker compose up -d --force-recreate api\n\n# Check health\ndocker compose ps api\n</code></pre></p>"},{"location":"v2/deployment/docker-compose/#monitoring-stack-not-starting","title":"Monitoring Stack Not Starting","text":"<p>Problem: Prometheus/Grafana containers missing</p> <p>Cause: Monitoring services behind <code>profiles: [monitoring]</code></p> <p>Solution: <pre><code># Start with monitoring profile\ndocker compose --profile monitoring up -d\n\n# Or: Explicitly start monitoring services\ndocker compose up -d prometheus grafana\n</code></pre></p>"},{"location":"v2/deployment/docker-compose/#media-upload-failures","title":"Media Upload Failures","text":"<p>Problem: Video uploads fail with <code>EACCES</code> or timeout</p> <p>Diagnosis: <pre><code># 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</code></pre></p> <p>Solution: <pre><code># 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</code></pre></p> <p>Important: Inbox must have <code>:rw</code> flag; main library stays <code>:ro</code>.</p>"},{"location":"v2/deployment/docker-compose/#production-deployment","title":"Production Deployment","text":""},{"location":"v2/deployment/docker-compose/#resource-limits","title":"Resource Limits","text":"<p>Production recommendations:</p> <pre><code># 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</code></pre> <p>Recommended limits: - <code>api</code>: 2 CPU, 2GB RAM - <code>media-api</code>: 2 CPU, 2GB RAM (for FFprobe) - <code>v2-postgres</code>: 2 CPU, 4GB RAM - <code>redis</code>: 1 CPU, 512MB RAM (already set) - <code>listmonk-app</code>: 1 CPU, 1GB RAM - <code>grafana</code>: 1 CPU, 512MB RAM</p>"},{"location":"v2/deployment/docker-compose/#healthcheck-tuning","title":"Healthcheck Tuning","text":"<p>Production healthcheck configuration:</p> <pre><code>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</code></pre> <p>Rationale: - Longer intervals reduce overhead - Higher retries prevent false positives - Longer start periods for slow database migrations</p>"},{"location":"v2/deployment/docker-compose/#log-management_1","title":"Log Management","text":"<p>Production logging configuration:</p> <pre><code># Add to all services\nlogging:\n driver: \"json-file\"\n options:\n max-size: \"10m\"\n max-file: \"5\"\n</code></pre> <p>Alternative: Use centralized logging (e.g., Loki + Promtail): <pre><code>logging:\n driver: \"loki\"\n options:\n loki-url: \"http://loki:3100/loki/api/v1/push\"\n</code></pre></p>"},{"location":"v2/deployment/docker-compose/#restart-policies","title":"Restart Policies","text":"<p>Production restart policies: - <code>restart: always</code> \u2014 For critical services (db, redis, api) - <code>restart: unless-stopped</code> \u2014 For most services (respects manual stops) - <code>restart: on-failure</code> \u2014 For optional services (monitoring)</p> <p>Current configuration: Most services use <code>unless-stopped</code> (allows manual shutdown).</p>"},{"location":"v2/deployment/docker-compose/#backup-strategy","title":"Backup Strategy","text":"<p>Automated backups (via cron): <pre><code># Add to crontab\n0 2 * * * /home/user/changemaker.lite/scripts/backup.sh --s3 >> /var/log/changemaker-backup.log 2>&1\n</code></pre></p> <p>What gets backed up: - V2 PostgreSQL database (pg_dump) - Listmonk PostgreSQL database (pg_dump) - Uploads directory (tar.gz)</p> <p>See Backup & Restore for complete procedures.</p>"},{"location":"v2/deployment/docker-compose/#security-hardening","title":"Security Hardening","text":"<p>Production checklist: - [ ] Change all default passwords in <code>.env</code> - [ ] Set strong <code>REDIS_PASSWORD</code> (required since Security Audit 2025-02-11) - [ ] Bind PostgreSQL ports to <code>127.0.0.1</code> (not <code>0.0.0.0</code>) - [ ] Enable SSL/TLS via Nginx (see SSL/TLS) - [ ] Set <code>ENCRYPTION_KEY</code> (must differ from JWT secrets) - [ ] Disable <code>EMAIL_TEST_MODE</code> (use real SMTP) - [ ] Set <code>NODE_ENV=production</code> - [ ] Review Nginx security headers (CSP, HSTS, Permissions-Policy) - [ ] Restrict NocoDB to read-only access (revoke INSERT/UPDATE/DELETE) - [ ] Enable Prometheus scraping authentication (basic auth)</p>"},{"location":"v2/deployment/docker-compose/#related-documentation","title":"Related Documentation","text":"<ul> <li>Environment Variables \u2014 Complete .env reference</li> <li>Nginx Configuration \u2014 Reverse proxy setup + subdomain routing</li> <li>SSL/TLS \u2014 Certificate management + HTTPS setup</li> <li>Tunneling \u2014 Pangolin tunnel deployment</li> <li>Monitoring Stack \u2014 Prometheus + Grafana configuration</li> <li>Backup & Restore \u2014 Database backup procedures</li> <li>Health Checks \u2014 Docker health check configuration</li> <li>Scaling \u2014 Horizontal scaling strategies</li> </ul>"},{"location":"v2/deployment/environment-variables/","title":"Environment Variables Reference","text":""},{"location":"v2/deployment/environment-variables/#overview","title":"Overview","text":"<p>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.</p> <p>Configuration File: <code>.env</code> (never committed to Git)</p> <p>Template: <code>.env.example</code> (committed, safe to share)</p> <p>Validation: <code>api/src/config/env.ts</code> (Zod schema validates all variables on startup)</p>"},{"location":"v2/deployment/environment-variables/#quick-start","title":"Quick Start","text":""},{"location":"v2/deployment/environment-variables/#initial-setup","title":"Initial Setup","text":"<pre><code># 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</code></pre>"},{"location":"v2/deployment/environment-variables/#minimal-required-variables","title":"Minimal Required Variables","text":"<p>Must set before first start: <pre><code>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</code></pre></p> <p>All other variables have safe defaults for development.</p>"},{"location":"v2/deployment/environment-variables/#general-configuration","title":"General Configuration","text":"Variable Default Required Description <code>NODE_ENV</code> <code>development</code> No Environment mode (<code>development</code> | <code>production</code>) <code>DOMAIN</code> <code>cmlite.org</code> No Base domain for subdomain routing <code>USER_ID</code> <code>1000</code> No Host user ID for volume permissions <code>GROUP_ID</code> <code>1000</code> No Host group ID for volume permissions <code>DOCKER_GROUP_ID</code> <code>984</code> No Docker group ID (for homepage container) <p>Usage: <pre><code>NODE_ENV=production docker compose up -d\n</code></pre></p>"},{"location":"v2/deployment/environment-variables/#v2-postgresql","title":"V2 PostgreSQL","text":"Variable Default Required Description <code>V2_POSTGRES_USER</code> <code>changemaker</code> No PostgreSQL username <code>V2_POSTGRES_PASSWORD</code> <code>CHANGE_ME_STRONG_PASSWORD</code> Yes PostgreSQL password <code>V2_POSTGRES_DB</code> <code>changemaker_v2</code> No Database name <code>V2_POSTGRES_PORT</code> <code>5433</code> No Host port (container always 5432) <p>Connection String (auto-generated in docker-compose.yml): <pre><code>postgresql://changemaker:PASSWORD@changemaker-v2-postgres:5432/changemaker_v2\n</code></pre></p> <p>Port Binding: <code>127.0.0.1:5433:5432</code> (localhost only for security)</p> <p>Important: Change <code>V2_POSTGRES_PASSWORD</code> before production deployment.</p>"},{"location":"v2/deployment/environment-variables/#jwt-authentication","title":"JWT Authentication","text":"Variable Default Required Description <code>JWT_ACCESS_SECRET</code> <code>GENERATE_WITH_openssl_rand_hex_32</code> Yes Access token secret (15min lifespan) <code>JWT_REFRESH_SECRET</code> <code>GENERATE_WITH_openssl_rand_hex_32</code> Yes Refresh token secret (7 day lifespan) <code>JWT_ACCESS_EXPIRY</code> <code>15m</code> No Access token expiration (<code>15m</code>, <code>1h</code>, etc.) <code>JWT_REFRESH_EXPIRY</code> <code>7d</code> No Refresh token expiration (<code>7d</code>, <code>30d</code>, etc.) <code>ENCRYPTION_KEY</code> <code>GENERATE_WITH_openssl_rand_hex_32</code> Yes (prod) DB encryption key for SMTP passwords, etc. <p>Security Requirements (enforced by Zod schema): - <code>JWT_ACCESS_SECRET</code> must be 32+ characters - <code>JWT_REFRESH_SECRET</code> must be 32+ characters - <code>ENCRYPTION_KEY</code> must be 32+ characters and differ from JWT secrets</p> <p>Generation: <pre><code>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</code></pre></p> <p>Production Note: <code>ENCRYPTION_KEY</code> required in production (dev mode allows empty for testing).</p>"},{"location":"v2/deployment/environment-variables/#redis","title":"Redis","text":"Variable Default Required Description <code>REDIS_PASSWORD</code> <code>CHANGE_ME_REDIS_PASSWORD</code> Yes Redis authentication password <code>REDIS_URL</code> <code>redis://:PASSWORD@redis-changemaker:6379</code> No Full connection URL (auto-generated) <p>Format: <code>redis://[:<password>@]<host>:<port>[/<db>]</code></p> <p>Example: <pre><code>REDIS_PASSWORD=mySecurePassword123\nREDIS_URL=redis://:mySecurePassword123@redis-changemaker:6379\n</code></pre></p> <p>Security Note: As of Security Audit 2025-02-11, Redis requires authentication in production.</p> <p>Docker Command (in docker-compose.yml): <pre><code>command: redis-server --appendonly yes --maxmemory 512mb --maxmemory-policy allkeys-lru --requirepass \"${REDIS_PASSWORD}\"\n</code></pre></p>"},{"location":"v2/deployment/environment-variables/#api-configuration","title":"API Configuration","text":"Variable Default Required Description <code>API_PORT</code> <code>4000</code> No Express API port (host) <code>API_URL</code> <code>http://localhost:4000</code> No Public API URL (for emails, OAuth redirects) <code>CORS_ORIGINS</code> <code>http://localhost:3000,http://localhost</code> No Allowed CORS origins (comma-separated) <p>Production Example: <pre><code>API_PORT=4000\nAPI_URL=https://api.cmlite.org\nCORS_ORIGINS=https://app.cmlite.org,https://cmlite.org\n</code></pre></p> <p>CORS Note: List all frontend origins (admin, public site, media gallery).</p>"},{"location":"v2/deployment/environment-variables/#admin-gui","title":"Admin GUI","text":"Variable Default Required Description <code>ADMIN_PORT</code> <code>3000</code> No Admin GUI port (host) <code>ADMIN_URL</code> <code>http://localhost:3000</code> No Public admin URL <code>VITE_API_URL</code> <code>http://changemaker-v2-api:4000</code> No API URL for Vite proxy (Docker internal) <code>VITE_MEDIA_API_URL</code> <code>http://changemaker-media-api:4100</code> No Media API URL for Vite proxy <code>VITE_MKDOCS_URL</code> <code>http://mkdocs-changemaker:8000</code> No MkDocs URL for iframe embed <p>Development vs Production:</p> <p>Development (Docker): <pre><code>VITE_API_URL=http://changemaker-v2-api:4000 # Container name\nVITE_MEDIA_API_URL=http://changemaker-media-api:4100\n</code></pre></p> <p>Development (local): <pre><code>VITE_API_URL=http://localhost:4000 # Localhost\nVITE_MEDIA_API_URL=http://localhost:4100\n</code></pre></p> <p>Production: Vite build embeds these URLs at build time.</p>"},{"location":"v2/deployment/environment-variables/#nginx","title":"Nginx","text":"Variable Default Required Description <code>NGINX_HTTP_PORT</code> <code>80</code> No HTTP port <code>NGINX_HTTPS_PORT</code> <code>443</code> No HTTPS port <p>Port Mapping (docker-compose.yml): <pre><code>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</code></pre></p> <p>Custom Ports (if 80/443 occupied): <pre><code>NGINX_HTTP_PORT=8080\nNGINX_HTTPS_PORT=8443\n</code></pre></p>"},{"location":"v2/deployment/environment-variables/#smtp-email","title":"SMTP / Email","text":"Variable Default Required Description <code>SMTP_HOST</code> <code>mailhog-changemaker</code> No SMTP server hostname <code>SMTP_PORT</code> <code>1025</code> No SMTP server port <code>SMTP_USER</code> `` No SMTP username (empty for MailHog) <code>SMTP_PASS</code> `` No SMTP password <code>SMTP_FROM</code> <code>noreply@cmlite.org</code> No Default sender email <code>SMTP_FROM_NAME</code> <code>Changemaker Lite</code> No Default sender name <code>EMAIL_TEST_MODE</code> <code>true</code> No Route all emails to MailHog (dev mode) <code>TEST_EMAIL_RECIPIENT</code> <code>admin@cmlite.org</code> No Override recipient in test mode <p>Development (MailHog): <pre><code>SMTP_HOST=mailhog-changemaker\nSMTP_PORT=1025\nSMTP_USER=\nSMTP_PASS=\nEMAIL_TEST_MODE=true\n</code></pre></p> <p>Production (e.g., ProtonMail): <pre><code>SMTP_HOST=smtp.protonmail.ch\nSMTP_PORT=587\nSMTP_USER=your@email.com\nSMTP_PASS=your-app-password\nEMAIL_TEST_MODE=false\n</code></pre></p> <p>Test Mode Behavior: - <code>true</code>: All emails sent to MailHog (visible at http://localhost:8025) - <code>false</code>: Emails sent to real recipients via SMTP</p> <p>SiteSettings Override: Admins can override SMTP config via <code>/app/settings</code> (stored encrypted in DB).</p>"},{"location":"v2/deployment/environment-variables/#listmonk","title":"Listmonk","text":""},{"location":"v2/deployment/environment-variables/#database","title":"Database","text":"Variable Default Required Description <code>LISTMONK_DB_PORT</code> <code>5432</code> No Listmonk PostgreSQL port <code>LISTMONK_DB_USER</code> <code>listmonk</code> No Database username <code>LISTMONK_DB_PASSWORD</code> <code>CHANGE_ME_LISTMONK_PASSWORD</code> Yes Database password <code>LISTMONK_DB_NAME</code> <code>listmonk</code> No Database name"},{"location":"v2/deployment/environment-variables/#web-admin","title":"Web Admin","text":"Variable Default Required Description <code>LISTMONK_PORT</code> <code>9001</code> No Listmonk web UI port <code>LISTMONK_WEB_ADMIN_USER</code> <code>admin</code> No Web UI username <code>LISTMONK_WEB_ADMIN_PASSWORD</code> <code>CHANGE_ME_LISTMONK_ADMIN</code> Yes Web UI password"},{"location":"v2/deployment/environment-variables/#api-integration","title":"API Integration","text":"Variable Default Required Description <code>LISTMONK_API_USER</code> <code>v2-api</code> No API user (auto-created by listmonk-init) <code>LISTMONK_API_TOKEN</code> <code>GENERATE_WITH_openssl_rand_hex_16</code> Yes API token (plaintext, not bcrypt) <code>LISTMONK_ADMIN_USER</code> <code>v2-api</code> No Alias for API user (V2 uses this) <code>LISTMONK_ADMIN_PASSWORD</code> <code>SAME_AS_LISTMONK_API_TOKEN</code> Yes Alias for API token <code>LISTMONK_SYNC_ENABLED</code> <code>false</code> No Enable participant/location sync <code>LISTMONK_PROXY_PORT</code> <code>9002</code> No OAuth proxy port (for future integrations) <p>API User Setup: The <code>listmonk-init</code> container auto-creates the API user by directly inserting into PostgreSQL.</p> <p>Token Generation: <pre><code>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</code></pre></p> <p>Sync Behavior: - <code>false</code>: Manual sync only (default) - <code>true</code>: Auto-sync participants/locations to Listmonk lists on signup/create</p>"},{"location":"v2/deployment/environment-variables/#smtp-configuration","title":"SMTP Configuration","text":"Variable Default Required Description <code>LISTMONK_SMTP_HOST</code> <code>mailhog-changemaker</code> No SMTP server for newsletters <code>LISTMONK_SMTP_PORT</code> <code>1025</code> No SMTP port <code>LISTMONK_SMTP_USER</code> `` No SMTP username <code>LISTMONK_SMTP_PASSWORD</code> `` No SMTP password <code>LISTMONK_SMTP_TLS_TYPE</code> <code>none</code> No TLS mode (<code>none</code> | <code>STARTTLS</code> | <code>TLS</code>) <code>LISTMONK_SMTP_FROM</code> <code>Changemaker Lite <noreply@cmlite.org></code> No Newsletter sender <p>listmonk-init Behavior: Configures dual SMTP providers (MailHog + production if credentials set).</p>"},{"location":"v2/deployment/environment-variables/#represent-api","title":"Represent API","text":"Variable Default Required Description <code>REPRESENT_API_URL</code> <code>https://represent.opennorth.ca</code> No Represent API endpoint (Canadian electoral data) <p>Free Public API: No authentication required.</p> <p>Usage: Postal code \u2192 representative lookup for Influence campaigns.</p>"},{"location":"v2/deployment/environment-variables/#nocodb","title":"NocoDB","text":"Variable Default Required Description <code>NOCODB_V2_PORT</code> <code>8091</code> No NocoDB web UI port <code>NOCODB_URL</code> <code>http://changemaker-v2-nocodb:8080</code> No Internal NocoDB URL <code>NC_ADMIN_EMAIL</code> <code>admin@cmlite.org</code> No Admin email <code>NC_ADMIN_PASSWORD</code> <code>CHANGE_ME_NOCODB_PASSWORD</code> Yes Admin password <code>NC_PUBLIC_URL</code> <code>http://localhost:8091</code> No Public NocoDB URL <p>Database Connection: Uses separate <code>nocodb_meta</code> database (auto-created by <code>init-nocodb-db.sh</code>).</p> <p>Connection String: <pre><code>pg://changemaker-v2-postgres:5432?u=changemaker&p=PASSWORD&d=nocodb_meta\n</code></pre></p>"},{"location":"v2/deployment/environment-variables/#media-management","title":"Media Management","text":"Variable Default Required Description <code>ENABLE_MEDIA_FEATURES</code> <code>false</code> No Enable media manager features <code>MEDIA_API_PORT</code> <code>4100</code> No Fastify media API port <code>MEDIA_API_PUBLIC_URL</code> <code>http://media-api:4100</code> No Public media API URL <code>MEDIA_ROOT</code> <code>/media/library</code> No Media library root path <code>MEDIA_UPLOADS</code> <code>/media/uploads</code> No Upload staging directory <code>MAX_UPLOAD_SIZE_GB</code> <code>10</code> No Max video upload size (GB) <code>PUBLIC_MEDIA_PORT</code> <code>3100</code> No Public media gallery port <code>VIDEO_PLAYER_DEBUG</code> <code>false</code> No Enable video.js debug logging <p>Feature Flag: Set <code>ENABLE_MEDIA_FEATURES=true</code> to activate media routes.</p> <p>Volume Mounts (in docker-compose.yml): <pre><code>volumes:\n - ${MEDIA_ROOT:-./media}:/media:ro # Library (read-only)\n - ${MEDIA_ROOT:-./media}/local/inbox:/media/local/inbox:rw # Inbox (writable)\n</code></pre></p> <p>Supported Formats: MP4, MOV, AVI, MKV, WebM, M4V, FLV</p>"},{"location":"v2/deployment/environment-variables/#gitea","title":"Gitea","text":"Variable Default Required Description <code>GITEA_URL</code> <code>http://gitea-changemaker:3000</code> No Internal Gitea URL <code>GITEA_WEB_PORT</code> <code>3030</code> No Gitea web UI port <code>GITEA_SSH_PORT</code> <code>2222</code> No Gitea SSH port (for git push/pull) <code>GITEA_DB_TYPE</code> <code>mysql</code> No Database type <code>GITEA_DB_HOST</code> <code>gitea-db:3306</code> No MySQL hostname <code>GITEA_DB_NAME</code> <code>gitea</code> No Database name <code>GITEA_DB_USER</code> <code>gitea</code> No Database username <code>GITEA_DB_PASSWD</code> <code>CHANGE_ME_GITEA_DB</code> Yes Database password <code>GITEA_DB_ROOT_PASSWORD</code> <code>CHANGE_ME_GITEA_ROOT</code> Yes MySQL root password <code>GITEA_ROOT_URL</code> <code>https://git.cmlite.org</code> No Public Gitea URL <code>GITEA_DOMAIN</code> <code>git.cmlite.org</code> No Gitea domain <p>First-Time Setup: Visit http://localhost:3030 to create admin account.</p> <p>Git Commands: <pre><code># 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</code></pre></p>"},{"location":"v2/deployment/environment-variables/#n8n","title":"n8n","text":"Variable Default Required Description <code>N8N_URL</code> <code>http://n8n-changemaker:5678</code> No Internal n8n URL <code>N8N_PORT</code> <code>5678</code> No n8n port <code>N8N_HOST</code> <code>n8n.cmlite.org</code> No Public n8n hostname <code>N8N_ENCRYPTION_KEY</code> <code>CHANGE_ME_N8N_KEY</code> Yes Workflow encryption key <code>N8N_USER_EMAIL</code> <code>admin@example.com</code> No Default admin email <code>N8N_USER_PASSWORD</code> <code>CHANGE_ME_N8N_PASSWORD</code> Yes Default admin password <code>GENERIC_TIMEZONE</code> <code>UTC</code> No Workflow timezone <p>First Start: n8n creates admin user with <code>N8N_USER_EMAIL</code>/<code>N8N_USER_PASSWORD</code> automatically.</p> <p>Encryption Key: Used to encrypt credentials in workflows.</p>"},{"location":"v2/deployment/environment-variables/#mkdocs","title":"MkDocs","text":"Variable Default Required Description <code>MKDOCS_PORT</code> <code>4003</code> No MkDocs live preview port <code>MKDOCS_SITE_SERVER_PORT</code> <code>4001</code> No MkDocs static site port <code>BASE_DOMAIN</code> <code>https://cmlite.org</code> No Site URL for sitemap/canonical <code>MKDOCS_PREVIEW_URL</code> <code>http://mkdocs:8000</code> No Internal preview URL <code>MKDOCS_DOCS_PATH</code> <code>/mkdocs/docs</code> No Documentation source path <p>Port Change: Was 4000 in V1, changed to 4003 to avoid conflict with API.</p> <p>Live Reload: http://localhost:4003 (updates on file save)</p> <p>Static Build: http://localhost:4001 (Nginx-served production build)</p>"},{"location":"v2/deployment/environment-variables/#code-server","title":"Code Server","text":"Variable Default Required Description <code>CODE_SERVER_PORT</code> <code>8888</code> No Code Server port <code>CODE_SERVER_URL</code> <code>http://code-server:8080</code> No Internal Code Server URL <code>USER_NAME</code> <code>coder</code> No Code Server username <p>Access: http://localhost:8888</p> <p>Password: Set in <code>configs/code-server/.config/code-server/config.yaml</code></p>"},{"location":"v2/deployment/environment-variables/#homepage","title":"Homepage","text":"Variable Default Required Description <code>HOMEPAGE_PORT</code> <code>3010</code> No Homepage dashboard port <code>HOMEPAGE_VAR_BASE_URL</code> <code>http://localhost</code> No Base URL for service links <p>Configuration: Edit <code>configs/homepage/services.yaml</code> to customize dashboard.</p>"},{"location":"v2/deployment/environment-variables/#mini-qr","title":"Mini QR","text":"Variable Default Required Description <code>MINI_QR_PORT</code> <code>8089</code> No Mini QR service port <code>MINI_QR_URL</code> <code>http://mini-qr:8080</code> No Internal Mini QR URL <code>MINI_QR_EMBED_PORT</code> <code>8885</code> No Nginx embed proxy port <p>Usage: Walk sheets + cut exports embed QR codes via API or iframe.</p>"},{"location":"v2/deployment/environment-variables/#mailhog","title":"MailHog","text":"Variable Default Required Description <code>MAILHOG_SMTP_PORT</code> <code>1025</code> No SMTP port (internal only) <code>MAILHOG_WEB_PORT</code> <code>8025</code> No Web UI port <p>Web UI: http://localhost:8025</p> <p>SMTP: Only accessible from Docker network (not exposed to host).</p>"},{"location":"v2/deployment/environment-variables/#nar-import","title":"NAR Import","text":"Variable Default Required Description <code>NAR_DATA_DIR</code> <code>/data</code> No Path to NAR data directory (in container) <p>Host Mount (in docker-compose.yml): <pre><code>volumes:\n - ./data:/data:ro # Read-only NAR data\n</code></pre></p> <p>Data Structure: <pre><code>./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</code></pre></p> <p>Download: https://www150.statcan.gc.ca/n1/pub/46-26-0002/462600022022001-eng.htm</p>"},{"location":"v2/deployment/environment-variables/#geocoding","title":"Geocoding","text":"Variable Default Required Description <code>MAPBOX_API_KEY</code> `` No Mapbox API key (optional, 100k free/month) <code>GEOCODING_RATE_LIMIT_MS</code> <code>1100</code> No Delay between provider requests (ms) <code>GEOCODING_CACHE_ENABLED</code> <code>true</code> No Enable Redis caching <code>GEOCODING_CACHE_TTL_HOURS</code> <code>24</code> No Cache TTL in hours <code>GOOGLE_MAPS_API_KEY</code> `` No Google Maps API key (optional, paid) <code>GOOGLE_MAPS_ENABLED</code> <code>false</code> No Enable Google geocoding provider <code>GEOCODING_PARALLEL_ENABLED</code> <code>true</code> No Parallel geocoding for bulk imports <code>GEOCODING_BATCH_SIZE</code> <code>10</code> No Batch size for parallel geocoding <code>BULK_GEOCODE_ENABLED</code> <code>true</code> No Enable bulk re-geocode feature <code>BULK_GEOCODE_MAX_BATCH</code> <code>5000</code> No Max locations per bulk geocode batch <p>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)</p> <p>Recommendation: Add <code>MAPBOX_API_KEY</code> for better accuracy without cost.</p>"},{"location":"v2/deployment/environment-variables/#pangolin-tunnel","title":"Pangolin Tunnel","text":"Variable Default Required Description <code>PANGOLIN_API_URL</code> <code>https://api.bnkserve.org/v1</code> No Pangolin API endpoint <code>PANGOLIN_API_KEY</code> `` No Pangolin API key <code>PANGOLIN_ORG_ID</code> `` No Organization ID (from setup wizard) <code>PANGOLIN_SITE_ID</code> `` No Site ID (from setup wizard) <code>PANGOLIN_ENDPOINT</code> <code>https://pangolin.bnkserve.org</code> No Tunnel endpoint URL <code>PANGOLIN_NEWT_ID</code> `` No Newt connector ID <code>PANGOLIN_NEWT_SECRET</code> `` No Newt connector secret <p>Setup Workflow: 1. Visit <code>/app/pangolin</code> in admin GUI 2. Enter <code>PANGOLIN_API_KEY</code> 3. Create org \u2192 site \u2192 endpoint \u2192 resource 4. Copy <code>NEWT_ID</code>/<code>NEWT_SECRET</code> to <code>.env</code> 5. Restart Newt container</p> <p>Manual Setup: <pre><code># 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</code></pre></p> <p>See Tunneling for complete guide.</p>"},{"location":"v2/deployment/environment-variables/#monitoring","title":"Monitoring","text":""},{"location":"v2/deployment/environment-variables/#prometheus","title":"Prometheus","text":"Variable Default Required Description <code>PROMETHEUS_PORT</code> <code>9090</code> No Prometheus port <p>Scrape Targets (configured in <code>configs/prometheus/prometheus.yml</code>): - <code>changemaker-v2-api:4000/api/metrics</code> (10s interval) - <code>redis-exporter:9121</code> (15s interval) - <code>cadvisor:8080</code> (15s interval) - <code>node-exporter:9100</code> (15s interval)</p> <p>Retention: 30 days (configured in docker-compose.yml command).</p>"},{"location":"v2/deployment/environment-variables/#grafana","title":"Grafana","text":"Variable Default Required Description <code>GRAFANA_PORT</code> <code>3001</code> No Grafana port <code>GRAFANA_ADMIN_PASSWORD</code> <code>admin</code> No Admin password <code>GRAFANA_ROOT_URL</code> <code>http://localhost:3001</code> No Public Grafana URL <p>Default Login: admin / admin (change on first login)</p> <p>Dashboards: 3 pre-configured dashboards auto-provisioned from <code>configs/grafana/</code></p>"},{"location":"v2/deployment/environment-variables/#exporters","title":"Exporters","text":"Variable Default Required Description <code>CADVISOR_PORT</code> <code>8080</code> No cAdvisor container metrics port <code>NODE_EXPORTER_PORT</code> <code>9100</code> No Node exporter system metrics port <code>REDIS_EXPORTER_PORT</code> <code>9121</code> No Redis exporter port"},{"location":"v2/deployment/environment-variables/#alertmanager","title":"Alertmanager","text":"Variable Default Required Description <code>ALERTMANAGER_PORT</code> <code>9093</code> No Alertmanager port <p>Configuration: Edit <code>configs/alertmanager/alertmanager.yml</code> for notification receivers.</p>"},{"location":"v2/deployment/environment-variables/#gotify","title":"Gotify","text":"Variable Default Required Description <code>GOTIFY_PORT</code> <code>8889</code> No Gotify push notification server port <code>GOTIFY_ADMIN_USER</code> <code>admin</code> No Gotify admin username <code>GOTIFY_ADMIN_PASSWORD</code> <code>admin</code> No Gotify admin password <p>Usage: Create apps in Gotify UI, add webhook URL to Alertmanager.</p>"},{"location":"v2/deployment/environment-variables/#security-checklist","title":"Security Checklist","text":"<p>Before production deployment:</p> <ul> <li> Change all <code>CHANGE_ME_*</code> passwords</li> <li> Generate strong <code>JWT_ACCESS_SECRET</code> (32+ chars)</li> <li> Generate strong <code>JWT_REFRESH_SECRET</code> (32+ chars)</li> <li> Generate strong <code>ENCRYPTION_KEY</code> (32+ chars, different from JWT secrets)</li> <li> Set strong <code>REDIS_PASSWORD</code></li> <li> Set strong <code>V2_POSTGRES_PASSWORD</code></li> <li> Set strong <code>LISTMONK_DB_PASSWORD</code></li> <li> Set strong <code>LISTMONK_API_TOKEN</code></li> <li> Set strong <code>GITEA_DB_PASSWD</code> + <code>GITEA_DB_ROOT_PASSWORD</code></li> <li> Set strong <code>N8N_ENCRYPTION_KEY</code> + <code>N8N_USER_PASSWORD</code></li> <li> Set strong <code>NC_ADMIN_PASSWORD</code> (NocoDB)</li> <li> Set strong <code>GRAFANA_ADMIN_PASSWORD</code></li> <li> Disable <code>EMAIL_TEST_MODE</code> (set to <code>false</code>)</li> <li> Configure real SMTP credentials</li> <li> Set <code>NODE_ENV=production</code></li> <li> Review <code>CORS_ORIGINS</code> (whitelist only trusted domains)</li> </ul> <p>Validation: <pre><code># 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</code></pre></p>"},{"location":"v2/deployment/environment-variables/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/deployment/environment-variables/#missing-env-file","title":"Missing .env File","text":"<p>Symptoms: Containers fail to start with \"missing environment variable\" errors</p> <p>Solution: <pre><code># Create from template\ncp .env.example .env\n\n# Verify file exists\nls -la .env\n</code></pre></p>"},{"location":"v2/deployment/environment-variables/#invalid-environment-variables","title":"Invalid Environment Variables","text":"<p>Symptoms: API fails to start with Zod validation errors</p> <p>Diagnosis: <pre><code># View API startup logs\ndocker compose logs api | grep -A10 \"Environment validation\"\n</code></pre></p> <p>Common errors: - <code>JWT_ACCESS_SECRET</code> too short (must be 32+ chars) - <code>ENCRYPTION_KEY</code> same as <code>JWT_ACCESS_SECRET</code> (must differ) - Invalid URL format (<code>API_URL</code> must start with http:// or https://)</p> <p>Solution: <pre><code># 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</code></pre></p>"},{"location":"v2/deployment/environment-variables/#postgresql-connection-failures","title":"PostgreSQL Connection Failures","text":"<p>Symptoms: API logs show <code>ECONNREFUSED</code> or <code>authentication failed</code></p> <p>Diagnosis: <pre><code># 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</code></pre></p> <p>Solution: <pre><code># 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</code></pre></p>"},{"location":"v2/deployment/environment-variables/#redis-connection-failures","title":"Redis Connection Failures","text":"<p>Symptoms: API logs show <code>ECONNREFUSED</code> or <code>WRONGPASS invalid password</code></p> <p>Diagnosis: <pre><code># Check Redis is running\ndocker compose ps redis\n\n# Test connection\ndocker compose exec redis redis-cli -a \"${REDIS_PASSWORD}\" ping\n</code></pre></p> <p>Solution: <pre><code># 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</code></pre></p>"},{"location":"v2/deployment/environment-variables/#environment-variables-not-updating","title":"Environment Variables Not Updating","text":"<p>Symptoms: Changed <code>.env</code> but service still uses old value</p> <p>Cause: Docker Compose reads <code>.env</code> at startup, not runtime</p> <p>Solution: <pre><code># 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</code></pre></p>"},{"location":"v2/deployment/environment-variables/#related-documentation","title":"Related Documentation","text":"<ul> <li>Docker Compose \u2014 Service orchestration</li> <li>SSL/TLS \u2014 Certificate management</li> <li>Tunneling \u2014 Pangolin setup</li> <li>Backup & Restore \u2014 Data protection</li> <li>Security Audit \u2014 Security requirements</li> </ul>"},{"location":"v2/deployment/healthchecks/","title":"Docker Health Check Configuration","text":""},{"location":"v2/deployment/healthchecks/#overview","title":"Overview","text":"<p>Docker health checks provide automatic service monitoring and restart capabilities. Changemaker Lite V2 includes health checks for 7 critical services.</p> <p>Benefits: - Automatic restart of unhealthy containers - Dependency management (<code>depends_on</code> with <code>service_healthy</code>) - Monitoring integration (Prometheus can scrape health status)</p>"},{"location":"v2/deployment/healthchecks/#services-with-health-checks","title":"Services with Health Checks","text":"Service Healthcheck Command Interval Timeout Retries Start Period api <code>wget http://localhost:4000/api/health</code> 15s 5s 3 30s media-api <code>wget http://127.0.0.1:4100/health</code> 15s 5s 3 30s admin <code>wget http://127.0.0.1:3000/</code> 30s 5s 3 20s v2-postgres <code>pg_isready -U changemaker</code> 10s 5s 5 - redis <code>redis-cli -a $REDIS_PASSWORD ping</code> 10s 5s 5 - gitea-app <code>curl http://localhost:3000/</code> 30s 5s 3 30s n8n <code>wget http://localhost:5678/healthz</code> 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":"<p>docker-compose.yml: <pre><code>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</code></pre></p> <p>Explanation: - test: Runs <code>wget</code> (Alpine image standard) to check <code>/api/health</code> 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)</p> <p>Health endpoint (api/src/server.ts): <pre><code>app.get('/api/health', (req, res) => {\n res.json({ status: 'ok', timestamp: new Date().toISOString() });\n});\n</code></pre></p> <p>Health states: - starting: Within start_period (30s) - healthy: Check passed - unhealthy: 3 consecutive failures</p>"},{"location":"v2/deployment/healthchecks/#media-api-fastify","title":"Media API (Fastify)","text":"<p>docker-compose.yml: <pre><code>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</code></pre></p> <p>Health endpoint (api/src/media-server.ts): <pre><code>app.get('/health', async (req, reply) => {\n return { status: 'ok' };\n});\n</code></pre></p> <p>Note: Uses <code>127.0.0.1</code> instead of <code>localhost</code> (Alpine's <code>wget</code> prefers IP).</p>"},{"location":"v2/deployment/healthchecks/#admin-vite-dev-server","title":"Admin (Vite Dev Server)","text":"<p>docker-compose.yml: <pre><code>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</code></pre></p> <p>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)</p>"},{"location":"v2/deployment/healthchecks/#v2-postgresql","title":"V2 PostgreSQL","text":"<p>docker-compose.yml: <pre><code>v2-postgres:\n healthcheck:\n test: [\"CMD-SHELL\", \"pg_isready -U changemaker\"]\n interval: 10s\n timeout: 5s\n retries: 5\n</code></pre></p> <p>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</p> <p>pg_isready output: <pre><code># Healthy\n/var/run/postgresql:5432 - accepting connections\n\n# Unhealthy\n/var/run/postgresql:5432 - rejecting connections\n</code></pre></p>"},{"location":"v2/deployment/healthchecks/#redis","title":"Redis","text":"<p>docker-compose.yml: <pre><code>redis:\n healthcheck:\n test: [\"CMD\", \"redis-cli\", \"-a\", \"${REDIS_PASSWORD}\", \"ping\"]\n interval: 10s\n timeout: 5s\n retries: 5\n</code></pre></p> <p>Explanation: - redis-cli ping: Returns <code>PONG</code> if healthy - -a ${REDIS_PASSWORD}: Authenticates with password (required since Security Audit) - 10s interval: Fast detection for critical cache service</p> <p>PING output: <pre><code># Healthy\nPONG\n\n# Unhealthy\n(error) NOAUTH Authentication required\n</code></pre></p>"},{"location":"v2/deployment/healthchecks/#gitea","title":"Gitea","text":"<p>docker-compose.yml: <pre><code>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</code></pre></p> <p>Explanation: - curl: Debian-based image (no <code>wget</code>) - -f: Fail on HTTP errors (non-200 response) - 30s interval: Supporting service (less critical)</p> <p>Important: Gitea uses <code>curl</code> (not <code>wget</code>) because it's a Debian image, not Alpine.</p>"},{"location":"v2/deployment/healthchecks/#n8n","title":"n8n","text":"<p>docker-compose.yml: <pre><code>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</code></pre></p> <p>Explanation: - /healthz: n8n's built-in health endpoint - 30s interval: Workflow automation (not user-facing)</p>"},{"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":"<p>docker-compose.yml: <pre><code>api:\n depends_on:\n v2-postgres:\n condition: service_healthy\n redis:\n condition: service_healthy\n</code></pre></p> <p>Effect: API container waits for PostgreSQL + Redis to be healthy before starting.</p> <p>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)</p>"},{"location":"v2/deployment/healthchecks/#media-api-depends-on-database","title":"Media API Depends on Database","text":"<p>docker-compose.yml: <pre><code>media-api:\n depends_on:\n v2-postgres:\n condition: service_healthy\n</code></pre></p> <p>Effect: Media API waits for PostgreSQL to be healthy.</p>"},{"location":"v2/deployment/healthchecks/#nocodb-depends-on-database","title":"NocoDB Depends on Database","text":"<p>docker-compose.yml: <pre><code>nocodb-v2:\n depends_on:\n v2-postgres:\n condition: service_healthy\n</code></pre></p> <p>Effect: NocoDB waits for its metadata database to be ready.</p>"},{"location":"v2/deployment/healthchecks/#monitoring-healthcheck-status","title":"Monitoring Healthcheck Status","text":""},{"location":"v2/deployment/healthchecks/#view-health-status","title":"View Health Status","text":"<pre><code># 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</code></pre> <p>Health states: - <code>(healthy)</code>: All checks passing - <code>(unhealthy)</code>: Multiple checks failed - <code>(health: starting)</code>: Within start_period</p>"},{"location":"v2/deployment/healthchecks/#filter-unhealthy-services","title":"Filter Unhealthy Services","text":"<pre><code># Show only unhealthy\ndocker compose ps | grep unhealthy\n\n# Count unhealthy\ndocker compose ps -q --status unhealthy | wc -l\n</code></pre>"},{"location":"v2/deployment/healthchecks/#inspect-health-check-details","title":"Inspect Health Check Details","text":"<pre><code># 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</code></pre> <p>Key fields: - Status: <code>healthy</code>, <code>unhealthy</code>, or <code>starting</code> - FailingStreak: Consecutive failed checks - Log: Last 5 health check results</p>"},{"location":"v2/deployment/healthchecks/#health-check-logs","title":"Health Check Logs","text":"<pre><code># 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</code></pre>"},{"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":"<p>Check database + Redis connectivity:</p> <p>api/src/server.ts: <pre><code>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</code></pre></p> <p>docker-compose.yml (no change needed \u2014 still checks <code>/api/health</code>): <pre><code>healthcheck:\n test: [\"CMD\", \"wget\", \"-q\", \"--spider\", \"http://localhost:4000/api/health\"]\n</code></pre></p>"},{"location":"v2/deployment/healthchecks/#readiness-vs-liveness","title":"Readiness vs Liveness","text":"<p>Readiness: Service is ready to accept traffic (used by Kubernetes) Liveness: Service is running (Docker health checks)</p> <p>Example (separate endpoints): <pre><code>// 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</code></pre></p> <p>Docker uses liveness (<code>/api/health</code>). Load balancer uses readiness (<code>/api/ready</code>).</p>"},{"location":"v2/deployment/healthchecks/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/deployment/healthchecks/#service-marked-unhealthy","title":"Service Marked Unhealthy","text":"<p>Diagnosis: <pre><code># 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</code></pre></p> <p>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)</p>"},{"location":"v2/deployment/healthchecks/#container-restarting-loop","title":"Container Restarting Loop","text":"<p>Symptoms: Container repeatedly marked unhealthy \u2192 restart \u2192 unhealthy</p> <p>Diagnosis: <pre><code># 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</code></pre></p> <p>Common causes: - Health check too aggressive (increase retries/interval) - Service genuinely broken (fix code issue) - Resource limits too low (increase memory/CPU)</p> <p>Solution: <pre><code># Temporarily disable health check\nhealthcheck:\n disable: true\n\n# Or increase tolerance\nhealthcheck:\n retries: 10\n start_period: 60s\n</code></pre></p>"},{"location":"v2/deployment/healthchecks/#health-check-command-not-found","title":"Health Check Command Not Found","text":"<p>Symptoms: Health check fails with \"wget: not found\" or \"curl: not found\"</p> <p>Cause: Using wrong command for image type (Alpine vs Debian)</p> <p>Solution:</p> <p>Alpine images (api, media-api, redis, v2-postgres): <pre><code>test: [\"CMD\", \"wget\", \"-q\", \"--spider\", \"http://...\"]\n</code></pre></p> <p>Debian images (gitea-app): <pre><code>test: [\"CMD\", \"curl\", \"-f\", \"http://...\"]\n</code></pre></p>"},{"location":"v2/deployment/healthchecks/#start-period-too-short","title":"Start Period Too Short","text":"<p>Symptoms: Service marked unhealthy immediately on startup</p> <p>Cause: Database migrations or slow startup exceed start_period</p> <p>Solution: <pre><code># Increase start_period\nhealthcheck:\n start_period: 60s # Was 30s\n</code></pre></p> <p>Monitor startup time: <pre><code># 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</code></pre></p>"},{"location":"v2/deployment/healthchecks/#production-recommendations","title":"Production Recommendations","text":""},{"location":"v2/deployment/healthchecks/#timeout-configuration","title":"Timeout Configuration","text":"<p>Critical services (database, redis, api): - interval: 10-15s - timeout: 5s - retries: 3-5 - start_period: 30-60s</p> <p>Supporting services (n8n, gitea, mailhog): - interval: 30-60s - timeout: 10s - retries: 3 - start_period: 30s</p>"},{"location":"v2/deployment/healthchecks/#restart-policies","title":"Restart Policies","text":"<p>Combine with restart policies: <pre><code>api:\n restart: unless-stopped # Auto-restart on failure\n healthcheck:\n test: [\"CMD\", \"wget\", \"-q\", \"--spider\", \"http://localhost:4000/api/health\"]\n</code></pre></p> <p>Effect: Unhealthy container \u2192 restart \u2192 health checks resume.</p>"},{"location":"v2/deployment/healthchecks/#monitoring-integration","title":"Monitoring Integration","text":"<p>Prometheus exporter (future): <pre><code># Expose health check status as metrics\ndocker_healthcheck_status{container=\"changemaker-v2-api\"} 1\n</code></pre></p> <p>Alert on unhealthy: <pre><code>- 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</code></pre></p>"},{"location":"v2/deployment/healthchecks/#testing-health-checks","title":"Testing Health Checks","text":""},{"location":"v2/deployment/healthchecks/#manual-test","title":"Manual Test","text":"<pre><code># 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</code></pre>"},{"location":"v2/deployment/healthchecks/#simulate-failure","title":"Simulate Failure","text":"<pre><code># 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</code></pre>"},{"location":"v2/deployment/healthchecks/#related-documentation","title":"Related Documentation","text":"<ul> <li>Docker Compose \u2014 Service orchestration</li> <li>Monitoring Stack \u2014 Health metrics</li> <li>Troubleshooting \u2014 Debug failing services</li> </ul>"},{"location":"v2/deployment/monitoring-stack/","title":"Monitoring Stack (Prometheus + Grafana)","text":""},{"location":"v2/deployment/monitoring-stack/#overview","title":"Overview","text":"<p>Changemaker Lite V2 includes a complete observability stack for production monitoring:</p> <ul> <li>Prometheus: Metrics collection + alerting rules</li> <li>Grafana: Visualization + pre-configured dashboards</li> <li>Alertmanager: Alert routing + notifications</li> <li>cAdvisor: Docker container metrics</li> <li>Node Exporter: Host system metrics</li> <li>Redis Exporter: Redis-specific metrics</li> <li>Gotify: Push notifications (optional)</li> </ul> <p>All monitoring services behind Docker Compose profile flag (opt-in).</p>"},{"location":"v2/deployment/monitoring-stack/#architecture","title":"Architecture","text":"<pre><code>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</code></pre>"},{"location":"v2/deployment/monitoring-stack/#quick-start","title":"Quick Start","text":""},{"location":"v2/deployment/monitoring-stack/#enable-monitoring","title":"Enable Monitoring","text":"<pre><code># 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</code></pre>"},{"location":"v2/deployment/monitoring-stack/#prometheus-configuration","title":"Prometheus Configuration","text":""},{"location":"v2/deployment/monitoring-stack/#scrape-targets","title":"Scrape Targets","text":"<p>File: <code>configs/prometheus/prometheus.yml</code></p> <pre><code>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</code></pre> <p>Intervals: - 10s: API (real-time application metrics) - 15s: Infrastructure (host + containers + Redis) - 30s: Monitoring stack itself</p>"},{"location":"v2/deployment/monitoring-stack/#custom-metrics-cm_","title":"Custom Metrics (cm_*)","text":"<p>File: <code>api/src/utils/metrics.ts</code></p> <p>12 custom metrics for domain-specific monitoring:</p> Metric Type Labels Description <code>cm_emails_sent_total</code> Counter <code>campaign_id</code> Campaign emails sent successfully <code>cm_emails_failed_total</code> Counter <code>campaign_id</code>, <code>error_type</code> Failed email sends <code>cm_email_queue_size</code> Gauge - Current email queue size <code>cm_email_send_duration_seconds</code> Histogram - Email send latency <code>cm_login_attempts_total</code> Counter <code>status</code> Login attempts (success/failure) <code>cm_active_sessions</code> Gauge - Active refresh tokens <code>cm_campaign_emails_total</code> Counter <code>campaign_id</code> Total campaign emails created <code>cm_response_submissions_total</code> Counter - Response wall submissions <code>cm_canvass_visits_total</code> Counter <code>outcome</code> Canvass visits by outcome <code>cm_active_canvass_sessions</code> Gauge - Active canvass sessions <code>cm_shift_signups_total</code> Counter - Shift signups <code>cm_external_service_up</code> Gauge <code>service</code> External service health (1=up, 0=down) <p>HTTP metrics (standard prom-client): - <code>http_requests_total</code> - <code>http_request_duration_seconds</code></p> <p>Geocoding metrics: - <code>cm_geocode_cache_hits_total</code> - <code>cm_geocode_cache_misses_total</code> - <code>cm_geocode_requests_total</code> - <code>cm_geocode_duration_seconds</code></p> <p>Email template metrics: - <code>cm_email_templates_updated_total</code> - <code>cm_email_test_sent_total</code> - <code>cm_email_template_rollback_total</code> - <code>cm_email_template_cache_hit/miss_total</code></p> <p>Location query metrics: - <code>cm_map_location_query_duration_seconds</code> - <code>cm_map_location_query_count_total</code> - <code>cm_map_location_result_count</code></p>"},{"location":"v2/deployment/monitoring-stack/#alert-rules","title":"Alert Rules","text":"<p>File: <code>configs/prometheus/alerts.yml</code></p> <p>12 alert rules across 4 groups:</p>"},{"location":"v2/deployment/monitoring-stack/#application-alerts","title":"Application Alerts","text":"<ol> <li>ApplicationDown: API unreachable for 2 minutes</li> <li>HighErrorRate: >10% 5xx errors for 5 minutes</li> <li>EmailQueueBacklog: Queue size >100 for 10 minutes</li> <li>HighEmailFailureRate: >20% email failures for 10 minutes</li> <li>SuspiciousLoginActivity: >5 failed logins/sec for 2 minutes</li> <li>HighAPILatency: P95 latency >2s for 5 minutes</li> <li>ExternalServiceDown: External service unreachable for 5 minutes</li> </ol>"},{"location":"v2/deployment/monitoring-stack/#system-alerts","title":"System Alerts","text":"<ol> <li>RedisDown: Redis unreachable for 1 minute</li> <li>DiskSpaceLow: <15% disk space for 5 minutes</li> <li>DiskSpaceCritical: <10% disk space for 2 minutes</li> <li>HighCPUUsage: >85% CPU for 10 minutes</li> <li>HighMemoryUsage: >85% memory for 10 minutes</li> </ol> <p>Example Alert: <pre><code>- 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</code></pre></p>"},{"location":"v2/deployment/monitoring-stack/#data-retention","title":"Data Retention","text":"<p>docker-compose.yml: <pre><code>prometheus:\n command:\n - '--storage.tsdb.retention.time=30d' # 30 days\n</code></pre></p> <p>Disk usage: ~1-5GB for 30 days (depends on scrape frequency + cardinality).</p> <p>Increase retention: <pre><code># 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</code></pre></p>"},{"location":"v2/deployment/monitoring-stack/#grafana-configuration","title":"Grafana Configuration","text":""},{"location":"v2/deployment/monitoring-stack/#datasource","title":"Datasource","text":"<p>File: <code>configs/grafana/datasources.yml</code></p> <pre><code>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</code></pre> <p>Auto-provisioned on Grafana startup.</p>"},{"location":"v2/deployment/monitoring-stack/#dashboards","title":"Dashboards","text":"<p>File: <code>configs/grafana/dashboards.yml</code></p> <pre><code>apiVersion: 1\n\nproviders:\n - name: 'Default'\n folder: 'Changemaker Lite'\n type: file\n options:\n path: /etc/grafana/provisioning/dashboards\n</code></pre> <p>3 pre-configured dashboards:</p>"},{"location":"v2/deployment/monitoring-stack/#1-application-overview","title":"1. Application Overview","text":"<p>File: <code>configs/grafana/application-overview.json</code></p> <p>Panels: - API uptime (last 24h) - Request rate (req/sec) - Error rate (%) - Email queue size - Active sessions - Campaign emails sent</p> <p>Refresh: 10s</p>"},{"location":"v2/deployment/monitoring-stack/#2-api-performance","title":"2. API Performance","text":"<p>File: <code>configs/grafana/api-performance.json</code></p> <p>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</p> <p>Refresh: 30s</p>"},{"location":"v2/deployment/monitoring-stack/#3-system-health","title":"3. System Health","text":"<p>File: <code>configs/grafana/system-health.json</code></p> <p>Panels: - CPU usage (%) - Memory usage (%) - Disk space (GB free) - Network I/O (MB/s) - Container CPU throttling - Redis memory usage</p> <p>Refresh: 1m</p>"},{"location":"v2/deployment/monitoring-stack/#first-login","title":"First Login","text":"<pre><code># Access Grafana\nopen http://localhost:3001\n\n# Default credentials\nUsername: admin\nPassword: admin\n\n# Change password on first login\n</code></pre> <p>Navigate: Dashboards \u2192 Changemaker Lite folder \u2192 Select dashboard</p>"},{"location":"v2/deployment/monitoring-stack/#alertmanager-configuration","title":"Alertmanager Configuration","text":""},{"location":"v2/deployment/monitoring-stack/#notification-receivers","title":"Notification Receivers","text":"<p>File: <code>configs/alertmanager/alertmanager.yml</code></p> <pre><code>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</code></pre> <p>Grouping: Combines similar alerts (prevents spam).</p> <p>Repeat: Re-sends unresolved alerts every 4 hours.</p>"},{"location":"v2/deployment/monitoring-stack/#testing-alerts","title":"Testing Alerts","text":"<p>Manual test: <pre><code># 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</code></pre></p> <p>Force alert (stop API): <pre><code># 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</code></pre></p>"},{"location":"v2/deployment/monitoring-stack/#exporters","title":"Exporters","text":""},{"location":"v2/deployment/monitoring-stack/#cadvisor-container-metrics","title":"cAdvisor (Container Metrics)","text":"<p>Metrics: - CPU usage per container - Memory usage per container - Network I/O - Disk I/O</p> <p>Access: http://localhost:8080</p> <p>Configuration (docker-compose.yml): <pre><code>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</code></pre></p>"},{"location":"v2/deployment/monitoring-stack/#node-exporter-host-metrics","title":"Node Exporter (Host Metrics)","text":"<p>Metrics: - CPU usage (all cores) - Memory usage (total, free, cached) - Disk usage (filesystem, mountpoints) - Network I/O (bytes, packets)</p> <p>Access: http://localhost:9100/metrics</p> <p>Configuration: <pre><code>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</code></pre></p>"},{"location":"v2/deployment/monitoring-stack/#redis-exporter","title":"Redis Exporter","text":"<p>Metrics: - Memory usage - Commands per second - Connected clients - Keyspace hits/misses - Evicted keys</p> <p>Access: http://localhost:9121/metrics</p> <p>Configuration: <pre><code>redis-exporter:\n environment:\n - REDIS_ADDR=redis:6379\n - REDIS_PASSWORD=${REDIS_PASSWORD} # Authenticates with Redis\n</code></pre></p>"},{"location":"v2/deployment/monitoring-stack/#gotify-push-notifications","title":"Gotify (Push Notifications)","text":"<p>Setup: <pre><code># 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</code></pre></p> <p>Mobile apps: Available for iOS/Android (receive push notifications).</p>"},{"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":"<p>Symptoms: Missing data in Grafana dashboards</p> <p>Diagnosis: <pre><code># 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</code></pre></p> <p>Common causes: - API container not running - Wrong port in <code>prometheus.yml</code> - Network connectivity issue</p> <p>Solution: <pre><code># 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</code></pre></p>"},{"location":"v2/deployment/monitoring-stack/#grafana-dashboards-not-loading","title":"Grafana Dashboards Not Loading","text":"<p>Symptoms: Blank dashboards or \"No data\" errors</p> <p>Diagnosis: <pre><code># 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</code></pre></p> <p>Solution: <pre><code># Verify datasource URL\n# Should be http://prometheus:9090 (container name, not localhost)\n\n# Restart Grafana\ndocker compose restart grafana\n</code></pre></p>"},{"location":"v2/deployment/monitoring-stack/#alerts-not-firing","title":"Alerts Not Firing","text":"<p>Symptoms: No notifications despite issues</p> <p>Diagnosis: <pre><code># 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</code></pre></p> <p>Solution: <pre><code># 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</code></pre></p>"},{"location":"v2/deployment/monitoring-stack/#production-best-practices","title":"Production Best Practices","text":""},{"location":"v2/deployment/monitoring-stack/#secure-grafana","title":"Secure Grafana","text":"<p>Change admin password: <pre><code># 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</code></pre></p> <p>Disable signup: <pre><code>environment:\n - GF_USERS_ALLOW_SIGN_UP=false # Already set\n</code></pre></p>"},{"location":"v2/deployment/monitoring-stack/#alert-tuning","title":"Alert Tuning","text":"<p>Avoid false positives: Increase <code>for</code> duration in critical alerts.</p> <p>Example (before): <pre><code>- alert: DiskSpaceLow\n expr: disk_free_percent < 15\n for: 1m # Too aggressive\n</code></pre></p> <p>Example (after): <pre><code>- alert: DiskSpaceLow\n expr: disk_free_percent < 15\n for: 10m # More reasonable\n</code></pre></p>"},{"location":"v2/deployment/monitoring-stack/#external-storage-long-term","title":"External Storage (Long-Term)","text":"<p>Prometheus supports remote write to: - Thanos: Long-term storage (S3/GCS) - Cortex: Multi-tenant Prometheus - VictoriaMetrics: High-performance storage</p> <p>Example (Thanos): <pre><code># prometheus.yml\nremote_write:\n - url: \"http://thanos-receive:19291/api/v1/receive\"\n</code></pre></p>"},{"location":"v2/deployment/monitoring-stack/#related-documentation","title":"Related Documentation","text":"<ul> <li>Docker Compose \u2014 Monitoring services configuration</li> <li>Environment Variables \u2014 Monitoring env vars</li> <li>API Reference \u2014 Custom metrics implementation</li> </ul>"},{"location":"v2/deployment/nginx/","title":"Nginx Reverse Proxy Configuration","text":""},{"location":"v2/deployment/nginx/#overview","title":"Overview","text":"<p>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.</p> <p>Key Responsibilities:</p> <ul> <li>Subdomain Routing: <code>api.cmlite.org</code>, <code>app.cmlite.org</code>, <code>db.cmlite.org</code>, etc.</li> <li>SSL/TLS Termination: Handles HTTPS certificates (Let's Encrypt, Cloudflare, or Pangolin)</li> <li>Security Headers: CSP, HSTS, X-Frame-Options, Permissions-Policy</li> <li>Proxy Pass: Forwards requests to backend Docker containers</li> <li>Static File Serving: Serves admin GUI production builds + MkDocs site</li> <li>WebSocket Support: Upgrades connections for n8n, MailHog, MkDocs live reload</li> <li>Iframe Embedding: CSP policies allow admin to embed services (NocoDB, Gitea, etc.)</li> </ul> <p>Architecture:</p> <pre><code>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</code></pre>"},{"location":"v2/deployment/nginx/#architecture","title":"Architecture","text":"<pre><code>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</code></pre>"},{"location":"v2/deployment/nginx/#configuration-files","title":"Configuration Files","text":"<p>Nginx configuration split across multiple files:</p> File Purpose Type <code>nginx/nginx.conf</code> Global settings, gzip, security headers Main config <code>nginx/conf.d/default.conf</code> Localhost fallback, path-based routing Server block <code>nginx/conf.d/api.conf</code> API subdomain routing (Express + Fastify) Server block <code>nginx/conf.d/services.conf</code> Supporting service subdomains Server blocks (12+) <p>Configuration hierarchy: <pre><code>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</code></pre></p>"},{"location":"v2/deployment/nginx/#global-configuration-nginxconf","title":"Global Configuration (nginx.conf)","text":"<p>File: <code>nginx/nginx.conf</code></p>"},{"location":"v2/deployment/nginx/#worker-configuration","title":"Worker Configuration","text":"<pre><code>worker_processes auto;\nerror_log /var/log/nginx/error.log warn;\npid /var/run/nginx.pid;\n\nevents {\n worker_connections 1024;\n}\n</code></pre> <p>Explanation: - <code>worker_processes auto</code>: Detects CPU cores (1 worker per core) - <code>worker_connections 1024</code>: Max 1024 concurrent connections per worker - Total capacity: <code>auto \u00d7 1024</code> (e.g., 4 cores = 4096 connections)</p>"},{"location":"v2/deployment/nginx/#http-block","title":"HTTP Block","text":"<pre><code>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</code></pre> <p>Key Settings: - <code>sendfile on</code>: Optimized file serving (kernel-level copy) - <code>tcp_nopush on</code>: Sends HTTP headers in single packet - <code>client_max_body_size 50m</code>: Default upload limit (overridden per location)</p>"},{"location":"v2/deployment/nginx/#gzip-compression","title":"Gzip Compression","text":"<pre><code># 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</code></pre> <p>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)</p>"},{"location":"v2/deployment/nginx/#security-headers","title":"Security Headers","text":"<pre><code># 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</code></pre> <p>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</p> <p>Note: <code>X-Frame-Options</code> set per server block (not global).</p>"},{"location":"v2/deployment/nginx/#docker-dns-resolver","title":"Docker DNS Resolver","text":"<pre><code># Docker internal DNS \u2014 enables runtime resolution\nresolver 127.0.0.11 valid=30s;\n</code></pre> <p>Purpose: Docker's embedded DNS server at <code>127.0.0.11</code> resolves container names.</p> <p>Why needed: Allows Nginx to start even when optional services are down. Without this, Nginx fails to start if any upstream is missing.</p> <p>Usage pattern: <pre><code>location / {\n set $upstream_api http://changemaker-v2-api:4000;\n proxy_pass $upstream_api; # Resolves at request time, not config parse\n}\n</code></pre></p> <p>Alternative (fails if container missing): <pre><code>proxy_pass http://changemaker-v2-api:4000; # Resolved at config parse \u2014 fails if down\n</code></pre></p>"},{"location":"v2/deployment/nginx/#subdomain-routing","title":"Subdomain Routing","text":""},{"location":"v2/deployment/nginx/#default-server-localhost","title":"Default Server (localhost)","text":"<p>File: <code>nginx/conf.d/default.conf</code></p> <pre><code>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</code></pre> <p>Routing Logic: 1. Request to <code>http://localhost/api/media/videos</code> \u2192 media-api:4100 2. Request to <code>http://localhost/api/campaigns</code> \u2192 api:4000 3. Request to <code>http://localhost/</code> \u2192 admin:3000 4. Request to <code>http://localhost/gallery/</code> \u2192 public-media:80</p> <p>Important: <code>/api/media/</code> location must come before <code>/api/</code> in config file (longest prefix match).</p>"},{"location":"v2/deployment/nginx/#api-subdomain-apicmliteorg","title":"API Subdomain (api.cmlite.org)","text":"<p>File: <code>nginx/conf.d/api.conf</code></p> <pre><code>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</code></pre> <p>URL Mapping: - <code>http://api.cmlite.org/media/videos</code> \u2192 <code>http://changemaker-media-api:4100/api/videos</code> - <code>http://api.cmlite.org/auth/login</code> \u2192 <code>http://changemaker-v2-api:4000/auth/login</code></p> <p>Critical: Media API location includes <code>/api/</code> in <code>proxy_pass</code> to rewrite path.</p>"},{"location":"v2/deployment/nginx/#service-subdomains","title":"Service Subdomains","text":"<p>File: <code>nginx/conf.d/services.conf</code></p>"},{"location":"v2/deployment/nginx/#gitea-gitcmliteorg","title":"Gitea (git.cmlite.org)","text":"<pre><code>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</code></pre> <p>Key Features: - CSP <code>frame-ancestors</code>: Allows embedding in <code>app.cmlite.org</code> (admin GUI) - proxy_hide_header X-Frame-Options: Strips Gitea's default DENY policy - 2GB upload limit: For large repository pushes</p>"},{"location":"v2/deployment/nginx/#n8n-n8ncmliteorg","title":"n8n (n8n.cmlite.org)","text":"<pre><code>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</code></pre> <p>WebSocket Headers: - <code>Upgrade: $http_upgrade</code>: Passes WebSocket upgrade header - <code>Connection: \"upgrade\"</code>: Indicates protocol upgrade</p> <p>Required for: n8n workflow editor, MailHog live updates, MkDocs live reload</p>"},{"location":"v2/deployment/nginx/#nocodb-dbcmliteorg","title":"NocoDB (db.cmlite.org)","text":"<pre><code>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</code></pre> <p>Iframe Embedding: - <code>frame-ancestors 'self' app.cmlite.org</code>: Allows admin GUI to embed NocoDB - <code>proxy_hide_header X-Frame-Options</code>: Removes NocoDB's default SAMEORIGIN policy</p>"},{"location":"v2/deployment/nginx/#mkdocs-docscmliteorg","title":"MkDocs (docs.cmlite.org)","text":"<pre><code>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</code></pre> <p>Live Reload: MkDocs Material theme uses WebSocket for live reload during development.</p>"},{"location":"v2/deployment/nginx/#code-server-codecmliteorg","title":"Code Server (code.cmlite.org)","text":"<pre><code>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</code></pre> <p>WebSocket Usage: Code Server uses WebSockets for terminal, file watching, language server.</p>"},{"location":"v2/deployment/nginx/#mailhog-mailcmliteorg","title":"MailHog (mail.cmlite.org)","text":"<pre><code>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</code></pre> <p>Live Updates: MailHog uses WebSocket to push new emails to browser without polling.</p>"},{"location":"v2/deployment/nginx/#listmonk-listmonkcmliteorg","title":"Listmonk (listmonk.cmlite.org)","text":"<pre><code>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</code></pre> <p>No Iframe: Listmonk not embedded in admin (accessed directly), so <code>SAMEORIGIN</code> policy kept.</p>"},{"location":"v2/deployment/nginx/#grafana-grafanacmliteorg","title":"Grafana (grafana.cmlite.org)","text":"<pre><code>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</code></pre> <p>WebSocket: Grafana uses WebSocket for live dashboard updates.</p>"},{"location":"v2/deployment/nginx/#mini-qr-qrcmliteorg","title":"Mini QR (qr.cmlite.org)","text":"<pre><code>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</code></pre> <p>Iframe Embedding: Admin GUI embeds Mini QR for walk sheet previews.</p>"},{"location":"v2/deployment/nginx/#root-domain-cmliteorg","title":"Root Domain (cmlite.org)","text":"<pre><code>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</code></pre> <p>Purpose: Serves MkDocs static site (production build) on root domain.</p>"},{"location":"v2/deployment/nginx/#homepage-homecmliteorg","title":"Homepage (home.cmlite.org)","text":"<pre><code>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</code></pre> <p>Dashboard: Service status dashboard with Docker integration.</p>"},{"location":"v2/deployment/nginx/#embed-proxy-ports","title":"Embed Proxy Ports","text":"<p>Purpose: Allow admin GUI to iframe services via localhost ports (bypassing subdomain requirements).</p> <p>Ports: 8881-8885 (NocoDB, n8n, Gitea, MailHog, Mini QR)</p> <p>Configuration (in <code>services.conf</code>):</p> <pre><code># 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</code></pre> <p>Usage in Admin GUI: <pre><code><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</code></pre></p> <p>Exposed in docker-compose.yml: <pre><code>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</code></pre></p>"},{"location":"v2/deployment/nginx/#proxy-configuration","title":"Proxy Configuration","text":""},{"location":"v2/deployment/nginx/#standard-proxy-headers","title":"Standard Proxy Headers","text":"<p>All proxy locations should include:</p> <pre><code>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</code></pre> <p>Header Explanation: - <code>Host</code>: Preserves original hostname (e.g., <code>api.cmlite.org</code>) - <code>X-Real-IP</code>: Client's IP address - <code>X-Forwarded-For</code>: Chain of proxy IPs (adds to existing list) - <code>X-Forwarded-Proto</code>: HTTP or HTTPS (used by backend for redirect logic)</p>"},{"location":"v2/deployment/nginx/#websocket-upgrade","title":"WebSocket Upgrade","text":"<p>Required for: n8n, MailHog, MkDocs, Code Server, Grafana</p> <pre><code>proxy_set_header Upgrade $http_upgrade;\nproxy_set_header Connection \"upgrade\";\n</code></pre> <p>Explanation: - <code>Upgrade: websocket</code>: Browser requests protocol upgrade - <code>Connection: upgrade</code>: Indicates connection will persist</p> <p>Without these headers: WebSocket connections fail with 400 Bad Request.</p>"},{"location":"v2/deployment/nginx/#timeouts","title":"Timeouts","text":"<p>Default timeouts: <pre><code>proxy_read_timeout 300s; # 5 minutes\nproxy_connect_timeout 75s; # 75 seconds\n</code></pre></p> <p>Media API timeouts (video uploads): <pre><code>proxy_read_timeout 3600s; # 1 hour\nproxy_connect_timeout 75s;\n</code></pre></p> <p>Why longer: FFprobe video analysis + large file uploads take time.</p>"},{"location":"v2/deployment/nginx/#upload-size-limits","title":"Upload Size Limits","text":"<p>Global default (<code>nginx.conf</code>): <pre><code>client_max_body_size 50m;\n</code></pre></p> <p>Per-location overrides: - Media API: <code>client_max_body_size 10G;</code> (video uploads) - Gitea: <code>client_max_body_size 2048M;</code> (large git pushes)</p>"},{"location":"v2/deployment/nginx/#request-buffering","title":"Request Buffering","text":"<p>Media API (disable buffering for streaming uploads): <pre><code>proxy_request_buffering off;\n</code></pre></p> <p>Effect: Nginx streams request body directly to backend (no temp file).</p> <p>Benefits: - Lower disk I/O on Nginx server - Faster upload start time - Reduced memory usage</p> <p>Trade-off: Backend must handle slow clients (Fastify multipart does this).</p>"},{"location":"v2/deployment/nginx/#ssltls-configuration","title":"SSL/TLS Configuration","text":""},{"location":"v2/deployment/nginx/#certificate-paths","title":"Certificate Paths","text":"<p>Recommended structure: <pre><code>/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</code></pre></p> <p>Nginx SSL block: <pre><code>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</code></pre></p>"},{"location":"v2/deployment/nginx/#http-to-https-redirect","title":"HTTP to HTTPS Redirect","text":"<pre><code>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</code></pre>"},{"location":"v2/deployment/nginx/#hsts-header","title":"HSTS Header","text":"<p>Already applied globally (in <code>nginx.conf</code>): <pre><code>add_header Strict-Transport-Security \"max-age=31536000; includeSubDomains\" always;\n</code></pre></p> <p>Effect: Browser caches HTTPS requirement for 1 year.</p> <p>Important: Only enable after verifying HTTPS works (can't easily undo).</p>"},{"location":"v2/deployment/nginx/#wildcard-certificates","title":"Wildcard Certificates","text":"<p>For <code>*.cmlite.org</code> (Let's Encrypt DNS challenge): <pre><code>certbot certonly --dns-cloudflare \\\n --dns-cloudflare-credentials ~/.secrets/cloudflare.ini \\\n -d cmlite.org -d \"*.cmlite.org\"\n</code></pre></p> <p>Single cert covers all subdomains: - api.cmlite.org - app.cmlite.org - db.cmlite.org - etc.</p> <p>See SSL/TLS for complete certificate management.</p>"},{"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":"<p>Dockerfile multi-stage build (admin/Dockerfile): <pre><code># 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</code></pre></p> <p>Nginx serves static files (no Node.js in production): <pre><code>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</code></pre></p>"},{"location":"v2/deployment/nginx/#mkdocs-static-site","title":"MkDocs Static Site","text":"<p>Build process (via admin GUI or CLI): <pre><code>docker compose exec mkdocs mkdocs build\n</code></pre></p> <p>Output: <code>mkdocs/site/</code> directory with static HTML</p> <p>Served by <code>mkdocs-site-server</code> (Nginx Alpine container): <pre><code>mkdocs-site-server:\n image: lscr.io/linuxserver/nginx:latest\n volumes:\n - ./mkdocs/site:/config/www\n ports:\n - \"4004:80\"\n</code></pre></p> <p>Nginx config (in <code>configs/mkdocs-site/default.conf</code>): <pre><code>server {\n listen 80;\n root /config/www;\n index index.html;\n\n location / {\n try_files $uri $uri/ =404;\n }\n}\n</code></pre></p>"},{"location":"v2/deployment/nginx/#performance-optimization","title":"Performance Optimization","text":""},{"location":"v2/deployment/nginx/#gzip-compression_1","title":"Gzip Compression","text":"<p>Already enabled globally (see nginx.conf above).</p> <p>Compression ratio: - JSON responses: ~75% reduction - HTML/CSS/JS: ~60-70% reduction - Images/video: No compression (already compressed)</p> <p>Trade-off: Slight CPU increase (~5-10%) for bandwidth savings.</p>"},{"location":"v2/deployment/nginx/#caching-static-assets","title":"Caching Static Assets","text":"<pre><code>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</code></pre> <p>Effect: Browsers cache static assets for 1 year.</p> <p>Caveat: Use content hashing in filenames (Vite does this automatically).</p>"},{"location":"v2/deployment/nginx/#proxy-caching","title":"Proxy Caching","text":"<p>Optional (not enabled by default): <pre><code># 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</code></pre></p> <p>Use cases: - Public campaign listing (10-minute cache) - Public map data (5-minute cache) - Representative lookup (1-hour cache)</p> <p>Avoid caching: - Authenticated endpoints - POST/PUT/DELETE requests - Real-time data (canvass sessions, email queue)</p>"},{"location":"v2/deployment/nginx/#connection-pooling","title":"Connection Pooling","text":"<p>Keep-alive to backends: <pre><code>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</code></pre></p> <p>Benefits: - Reduced latency (no TCP handshake) - Lower CPU (fewer connection setups) - Better throughput under load</p>"},{"location":"v2/deployment/nginx/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/deployment/nginx/#502-bad-gateway","title":"502 Bad Gateway","text":"<p>Symptoms: <code>502 Bad Gateway</code> error</p> <p>Causes: 1. Backend container not running 2. Backend healthcheck failing 3. Backend listening on wrong port 4. Network connectivity issue</p> <p>Diagnosis: <pre><code># 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</code></pre></p> <p>Solution: <pre><code># 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</code></pre></p>"},{"location":"v2/deployment/nginx/#504-gateway-timeout","title":"504 Gateway Timeout","text":"<p>Symptoms: Request times out after 60 seconds</p> <p>Cause: Backend processing too slow, proxy timeout too short</p> <p>Solution: <pre><code># 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</code></pre></p>"},{"location":"v2/deployment/nginx/#ssl-certificate-errors","title":"SSL Certificate Errors","text":"<p>Symptoms: <code>SSL_ERROR_RX_RECORD_TOO_LONG</code> or <code>ERR_SSL_PROTOCOL_ERROR</code></p> <p>Cause: Accessing HTTPS port via HTTP or vice versa</p> <p>Diagnosis: <pre><code># 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</code></pre></p> <p>Solution: <pre><code># 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</code></pre></p>"},{"location":"v2/deployment/nginx/#cors-errors","title":"CORS Errors","text":"<p>Symptoms: Browser console shows <code>CORS policy: No 'Access-Control-Allow-Origin' header</code></p> <p>Cause: Backend not setting CORS headers</p> <p>Diagnosis: <pre><code># 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</code></pre></p> <p>Solution: CORS headers set by backend (not Nginx). Check <code>api/src/server.ts</code>: <pre><code>app.use(cors({\n origin: process.env.CORS_ORIGINS?.split(',') || ['http://localhost:3000'],\n credentials: true,\n}));\n</code></pre></p> <p>Nginx passthrough (don't modify CORS headers): <pre><code># DO NOT add these in Nginx (backend handles CORS)\n# add_header Access-Control-Allow-Origin \"*\"; # \u274c WRONG\n</code></pre></p>"},{"location":"v2/deployment/nginx/#websocket-connection-failures","title":"WebSocket Connection Failures","text":"<p>Symptoms: WebSocket upgrade fails with <code>400 Bad Request</code></p> <p>Cause: Missing <code>Upgrade</code>/<code>Connection</code> headers</p> <p>Diagnosis: <pre><code># Check Nginx config\ngrep -A5 \"Upgrade\" nginx/conf.d/services.conf\n\n# Test WebSocket\nwscat -c ws://localhost:5678\n</code></pre></p> <p>Solution: <pre><code># Add to location block\nproxy_set_header Upgrade $http_upgrade;\nproxy_set_header Connection \"upgrade\";\n</code></pre></p>"},{"location":"v2/deployment/nginx/#large-upload-failures","title":"Large Upload Failures","text":"<p>Symptoms: Upload fails with <code>413 Request Entity Too Large</code></p> <p>Cause: <code>client_max_body_size</code> too small</p> <p>Solution: <pre><code># 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</code></pre></p>"},{"location":"v2/deployment/nginx/#iframe-not-displaying","title":"Iframe Not Displaying","text":"<p>Symptoms: Service loads in new tab but not in iframe</p> <p>Cause: <code>X-Frame-Options: DENY</code> or CSP <code>frame-ancestors</code> blocking</p> <p>Diagnosis: <pre><code># Check response headers\ncurl -I http://db.cmlite.org\n\n# Look for X-Frame-Options or Content-Security-Policy\n</code></pre></p> <p>Solution: <pre><code># 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</code></pre></p>"},{"location":"v2/deployment/nginx/#nginx-wont-start","title":"Nginx Won't Start","text":"<p>Symptoms: <code>docker compose up</code> fails with Nginx error</p> <p>Diagnosis: <pre><code># 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</code></pre></p> <p>Common mistakes: - Missing semicolon - Duplicate <code>server_name</code> (same subdomain in multiple files) - Invalid regex in <code>location</code> - Unclosed <code>{</code> bracket</p>"},{"location":"v2/deployment/nginx/#production-best-practices","title":"Production Best Practices","text":""},{"location":"v2/deployment/nginx/#rate-limiting","title":"Rate Limiting","text":"<p>Limit requests per IP (prevents abuse): <pre><code># 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</code></pre></p> <p>Explanation: - <code>rate=10r/s</code>: 10 requests per second average - <code>burst=20</code>: Allow bursts up to 20 requests - <code>nodelay</code>: Process burst immediately (don't queue)</p>"},{"location":"v2/deployment/nginx/#security-headers-review","title":"Security Headers Review","text":"<p>Production checklist: - [x] HSTS enabled (<code>max-age=31536000</code>) - [x] <code>X-Content-Type-Options: nosniff</code> - [x] <code>X-XSS-Protection: 1; mode=block</code> - [x] CSP <code>frame-ancestors</code> for embeddable services - [x] <code>X-Frame-Options: SAMEORIGIN</code> for non-embedded services - [x] <code>Referrer-Policy: strict-origin-when-cross-origin</code> - [x] <code>Permissions-Policy</code> restricts sensors</p> <p>Optional enhancements: <pre><code># 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</code></pre></p>"},{"location":"v2/deployment/nginx/#access-logging","title":"Access Logging","text":"<p>Production log format (JSON for parsing): <pre><code>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</code></pre></p> <p>Benefits: Easy parsing with tools like <code>jq</code>, Logstash, Loki.</p>"},{"location":"v2/deployment/nginx/#error-page-customization","title":"Error Page Customization","text":"<p>Custom error pages: <pre><code>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</code></pre></p> <p>Create files: <pre><code>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</code></pre></p>"},{"location":"v2/deployment/nginx/#related-documentation","title":"Related Documentation","text":"<ul> <li>Docker Compose \u2014 Container orchestration</li> <li>Environment Variables \u2014 Configuration reference</li> <li>SSL/TLS \u2014 Certificate management</li> <li>Tunneling \u2014 Pangolin tunnel setup</li> <li>Scaling \u2014 Load balancing strategies</li> </ul>"},{"location":"v2/deployment/scaling/","title":"Horizontal Scaling Strategies","text":""},{"location":"v2/deployment/scaling/#overview","title":"Overview","text":"<p>Changemaker Lite V2 can scale horizontally to handle increased traffic and data volume. This guide covers strategies for scaling each component.</p> <p>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)</p>"},{"location":"v2/deployment/scaling/#database-scaling","title":"Database Scaling","text":""},{"location":"v2/deployment/scaling/#read-replicas","title":"Read Replicas","text":"<p>PostgreSQL streaming replication for read-heavy workloads.</p> <p>Setup (docker-compose.yml): <pre><code>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</code></pre></p> <p>Primary config (postgresql.conf): <pre><code>wal_level = replica\nmax_wal_senders = 3\nwal_keep_size = 64MB\n</code></pre></p> <p>Replication user: <pre><code>CREATE ROLE replicator WITH REPLICATION LOGIN PASSWORD 'replica-password';\n</code></pre></p> <p>Prisma read replica (planned feature): <pre><code>// 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</code></pre></p>"},{"location":"v2/deployment/scaling/#connection-pooling","title":"Connection Pooling","text":"<p>PgBouncer for connection pooling.</p> <p>docker-compose.yml: <pre><code>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</code></pre></p> <p>Update DATABASE_URL: <pre><code># 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</code></pre></p> <p>Benefits: - Handles 1000+ client connections with only 20 PostgreSQL connections - Reduces connection overhead - Prevents \"too many connections\" errors</p>"},{"location":"v2/deployment/scaling/#api-scaling","title":"API Scaling","text":""},{"location":"v2/deployment/scaling/#multiple-api-containers","title":"Multiple API Containers","text":"<p>docker-compose.yml: <pre><code>api:\n # ... existing config\n deploy:\n replicas: 3 # Run 3 API containers\n</code></pre></p> <p>Or manual scaling: <pre><code>docker compose up -d --scale api=3\n</code></pre></p> <p>Load balancer (Nginx upstream): <pre><code>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</code></pre></p> <p>Session affinity (sticky sessions): <pre><code>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</code></pre></p>"},{"location":"v2/deployment/scaling/#vertical-scaling-resource-limits","title":"Vertical Scaling (Resource Limits)","text":"<p>Increase container resources: <pre><code>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</code></pre></p> <p>Node.js memory limit: <pre><code>api:\n environment:\n - NODE_OPTIONS=--max-old-space-size=3072 # 3GB heap\n</code></pre></p>"},{"location":"v2/deployment/scaling/#redis-scaling","title":"Redis Scaling","text":""},{"location":"v2/deployment/scaling/#redis-cluster-sharding","title":"Redis Cluster (Sharding)","text":"<p>For >100GB datasets or high throughput.</p> <p>docker-compose.yml (6-node cluster): <pre><code>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</code></pre></p> <p>Create cluster: <pre><code>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</code></pre></p>"},{"location":"v2/deployment/scaling/#redis-sentinel-high-availability","title":"Redis Sentinel (High Availability)","text":"<p>Automatic failover for Redis.</p> <p>docker-compose.yml: <pre><code>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</code></pre></p> <p>sentinel.conf: <pre><code>sentinel monitor mymaster redis-primary 6379 2\nsentinel down-after-milliseconds mymaster 5000\nsentinel parallel-syncs mymaster 1\nsentinel failover-timeout mymaster 10000\n</code></pre></p>"},{"location":"v2/deployment/scaling/#media-api-scaling","title":"Media API Scaling","text":""},{"location":"v2/deployment/scaling/#separate-media-containers","title":"Separate Media Containers","text":"<p>docker-compose.yml: <pre><code>media-api:\n deploy:\n replicas: 2 # Run 2 media API containers\n</code></pre></p> <p>Nginx load balancer: <pre><code>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</code></pre></p> <p>Shared volume (read-only): <pre><code>media-api:\n volumes:\n - ${MEDIA_ROOT}:/media:ro # All replicas read same library\n</code></pre></p>"},{"location":"v2/deployment/scaling/#cdn-for-static-media","title":"CDN for Static Media","text":"<p>Cloudflare CDN (or similar):</p> <p>Setup: 1. Enable Cloudflare proxy (orange cloud) 2. Configure cache rules: - Cache <code>/media/library/*.mp4</code> for 30 days - Bypass cache for <code>/api/media/</code> (dynamic)</p> <p>Benefits: - Offload video bandwidth - Global edge caching - DDoS protection</p>"},{"location":"v2/deployment/scaling/#frontend-scaling","title":"Frontend Scaling","text":""},{"location":"v2/deployment/scaling/#cdn-for-static-assets","title":"CDN for Static Assets","text":"<p>Vite production build \u2192 static files \u2192 CDN.</p> <p>Build: <pre><code>cd admin && npm run build\n</code></pre></p> <p>Upload to CDN (S3 + CloudFront): <pre><code>aws s3 sync dist/ s3://changemaker-static/ --delete\naws cloudfront create-invalidation --distribution-id XYZ --paths \"/*\"\n</code></pre></p> <p>Benefits: - Global edge caching - Reduced origin load - Faster page loads</p>"},{"location":"v2/deployment/scaling/#nginx-caching","title":"Nginx Caching","text":"<p>Proxy cache for API responses: <pre><code>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</code></pre></p> <p>Cacheable endpoints: - <code>/api/campaigns</code> (public listing, 10 minutes) - <code>/api/representatives</code> (lookup cache, 1 hour) - <code>/api/locations/public</code> (map data, 5 minutes)</p> <p>Never cache: - POST/PUT/DELETE requests - Authenticated endpoints - Real-time data (canvass sessions)</p>"},{"location":"v2/deployment/scaling/#job-queue-scaling","title":"Job Queue Scaling","text":""},{"location":"v2/deployment/scaling/#multiple-bullmq-workers","title":"Multiple BullMQ Workers","text":"<p>API container scaling also scales workers (each container runs worker).</p> <p>Alternative: Dedicated worker containers.</p> <p>docker-compose.yml: <pre><code>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</code></pre></p> <p>Worker script (api/src/workers/email-worker.ts): <pre><code>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</code></pre></p> <p>Scale workers: <pre><code>docker compose up -d --scale email-worker=5\n</code></pre></p>"},{"location":"v2/deployment/scaling/#monitoring-under-load","title":"Monitoring Under Load","text":""},{"location":"v2/deployment/scaling/#load-testing","title":"Load Testing","text":"<p>k6 script (load-test.js): <pre><code>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</code></pre></p> <p>Run test: <pre><code>k6 run load-test.js\n</code></pre></p>"},{"location":"v2/deployment/scaling/#prometheus-metrics","title":"Prometheus Metrics","text":"<p>Monitor scaling indicators: - <code>rate(http_requests_total[5m])</code> \u2014 Request rate - <code>histogram_quantile(0.95, http_request_duration_seconds)</code> \u2014 P95 latency - <code>container_cpu_usage_seconds_total</code> \u2014 CPU usage per container - <code>container_memory_usage_bytes</code> \u2014 Memory usage per container</p> <p>Grafana alert: <pre><code>- 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</code></pre></p>"},{"location":"v2/deployment/scaling/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/deployment/scaling/#high-cpu-usage","title":"High CPU Usage","text":"<p>Diagnosis: <pre><code># 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</code></pre></p> <p>Solutions: - Scale API containers (3-5 replicas) - Increase CPU limit (2-4 cores) - Optimize slow queries (add indexes) - Enable caching (Nginx proxy cache)</p>"},{"location":"v2/deployment/scaling/#memory-leaks","title":"Memory Leaks","text":"<p>Diagnosis: <pre><code># 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</code></pre></p> <p>Solutions: - Restart containers daily (cron job) - Increase memory limit (4-8GB) - Fix code leaks (event listeners, circular refs)</p>"},{"location":"v2/deployment/scaling/#database-connection-exhaustion","title":"Database Connection Exhaustion","text":"<p>Symptoms: <code>Error: too many connections for role \"changemaker\"</code></p> <p>Diagnosis: <pre><code># 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</code></pre></p> <p>Solutions: - Add PgBouncer (connection pooling) - Increase <code>max_connections</code> (PostgreSQL config) - Fix connection leaks (always close Prisma clients)</p>"},{"location":"v2/deployment/scaling/#cost-optimization","title":"Cost Optimization","text":""},{"location":"v2/deployment/scaling/#resource-allocation","title":"Resource Allocation","text":"<p>Right-sizing (don't over-provision): - Start with 1 CPU, 1GB RAM per container - Monitor actual usage (Prometheus) - Scale based on metrics (not guesses)</p> <p>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)</p>"},{"location":"v2/deployment/scaling/#autoscaling-docker-swarm","title":"Autoscaling (Docker Swarm)","text":"<p>Docker Swarm mode (alternative to Compose): <pre><code># 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</code></pre></p> <p>Autoscaling: <pre><code>api:\n deploy:\n replicas: 3\n update_config:\n parallelism: 1\n delay: 10s\n restart_policy:\n condition: on-failure\n</code></pre></p>"},{"location":"v2/deployment/scaling/#related-documentation","title":"Related Documentation","text":"<ul> <li>Docker Compose \u2014 Container orchestration</li> <li>Monitoring Stack \u2014 Performance metrics</li> <li>Nginx Configuration \u2014 Load balancing</li> <li>Backup & Restore \u2014 Data protection at scale</li> </ul>"},{"location":"v2/deployment/ssl-tls/","title":"SSL/TLS Certificate Management","text":""},{"location":"v2/deployment/ssl-tls/#overview","title":"Overview","text":"<p>Changemaker Lite V2 supports multiple SSL/TLS certificate sources for HTTPS deployment:</p> <ul> <li>Let's Encrypt: Free automated certificates (recommended for self-hosted)</li> <li>Cloudflare Origin Certificates: Static 15-year certificates (if using Cloudflare)</li> <li>Pangolin Tunnel SSL: Tunnel provider handles SSL termination</li> </ul> <p>Recommendation: Use Pangolin tunnel for simplest setup (SSL handled by tunnel provider).</p>"},{"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":"<p>Best for: Self-hosted deployments with public DNS</p> <p>Process: 1. Install Certbot 2. Generate certificate (DNS or HTTP challenge) 3. Configure Nginx 4. Auto-renewal via cron</p> <p>Installation (Ubuntu/Debian): <pre><code>sudo apt update\nsudo apt install certbot python3-certbot-nginx\n</code></pre></p> <p>Generate Certificate (HTTP-01 challenge): <pre><code># 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</code></pre></p> <p>Certificate Location: <pre><code>/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</code></pre></p> <p>Nginx Configuration: <pre><code>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</code></pre></p> <p>HTTP Redirect: <pre><code>server {\n listen 80;\n server_name api.cmlite.org;\n return 301 https://$host$request_uri;\n}\n</code></pre></p>"},{"location":"v2/deployment/ssl-tls/#cloudflare-origin-certificates","title":"Cloudflare Origin Certificates","text":"<p>Best for: Sites using Cloudflare DNS + proxy</p> <p>Process: 1. Generate certificate in Cloudflare dashboard 2. Download certificate + private key 3. Install in Nginx 4. Set SSL mode to \"Full (strict)\"</p> <p>Generate Certificate: 1. Cloudflare dashboard \u2192 SSL/TLS \u2192 Origin Server 2. Click \"Create Certificate\" 3. Hostnames: <code>cmlite.org</code>, <code>*.cmlite.org</code> 4. Validity: 15 years 5. Download certificate + private key</p> <p>Install Certificate: <pre><code># 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</code></pre></p> <p>Nginx Configuration: <pre><code>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</code></pre></p> <p>Cloudflare SSL Mode: Set to \"Full (strict)\" (not \"Flexible\").</p>"},{"location":"v2/deployment/ssl-tls/#pangolin-tunnel-ssl","title":"Pangolin Tunnel SSL","text":"<p>Best for: Quick deployment without SSL management</p> <p>How it works: 1. Pangolin tunnel terminates SSL at tunnel endpoint 2. Traffic forwarded to your Nginx as HTTP 3. No certificate management needed</p> <p>Setup: <pre><code># 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</code></pre></p> <p>Nginx Configuration: Keep HTTP-only (tunnel handles HTTPS): <pre><code>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</code></pre></p> <p>DNS Setup: Point domain to tunnel endpoint (provided by Pangolin).</p> <p>See Tunneling for complete guide.</p>"},{"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":"<pre><code>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</code></pre> <p>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</p>"},{"location":"v2/deployment/ssl-tls/#http2","title":"HTTP/2","text":"<p>Already enabled (<code>:443 ssl http2</code>): - Multiplexes requests over single connection - Server push support (optional) - Faster page loads</p> <p>No additional config needed \u2014 Nginx handles HTTP/2 automatically.</p>"},{"location":"v2/deployment/ssl-tls/#hsts-http-strict-transport-security","title":"HSTS (HTTP Strict Transport Security)","text":"<p>Already set globally (in <code>nginx/nginx.conf</code>): <pre><code>add_header Strict-Transport-Security \"max-age=31536000; includeSubDomains\" always;\n</code></pre></p> <p>Effect: - Browsers cache HTTPS requirement for 1 year - Prevents downgrade attacks - Applies to all subdomains</p> <p>Warning: Only enable after verifying HTTPS works (can't easily undo).</p> <p>Test before enabling: <pre><code># Test HTTPS works\ncurl -I https://api.cmlite.org\n\n# Check for redirects\ncurl -L https://api.cmlite.org\n</code></pre></p>"},{"location":"v2/deployment/ssl-tls/#certificate-renewal","title":"Certificate Renewal","text":""},{"location":"v2/deployment/ssl-tls/#automated-renewal-certbot","title":"Automated Renewal (Certbot)","text":"<p>Setup cron job: <pre><code># 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</code></pre></p> <p>Manual renewal: <pre><code># 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</code></pre></p> <p>Renewal conditions: - Certificates expire in <30 days - HTTP-01 challenge succeeds (port 80 must be open)</p>"},{"location":"v2/deployment/ssl-tls/#manual-renewal-cloudflare","title":"Manual Renewal (Cloudflare)","text":"<p>Cloudflare origin certificates valid for 15 years \u2014 no renewal needed.</p> <p>If replacing certificate: 1. Generate new cert in Cloudflare dashboard 2. Download files 3. Replace files in <code>/etc/ssl/cloudflare/</code> 4. Reload Nginx: <code>docker compose exec nginx nginx -s reload</code></p>"},{"location":"v2/deployment/ssl-tls/#monitoring-expiry","title":"Monitoring Expiry","text":"<p>Check expiry date: <pre><code># 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</code></pre></p> <p>Automated monitoring (via Prometheus + Alertmanager): <pre><code># 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</code></pre></p>"},{"location":"v2/deployment/ssl-tls/#testing-ssl","title":"Testing SSL","text":""},{"location":"v2/deployment/ssl-tls/#ssl-labs","title":"SSL Labs","text":"<p>Online test: https://www.ssllabs.com/ssltest/</p> <p>Target grade: A or A+</p> <p>Common issues: - Missing intermediate certificate (use <code>fullchain.pem</code> not <code>cert.pem</code>) - Weak ciphers (update <code>ssl_ciphers</code> list) - Missing HSTS header (already set globally)</p>"},{"location":"v2/deployment/ssl-tls/#command-line","title":"Command Line","text":"<p>Test TLS handshake: <pre><code>openssl s_client -connect api.cmlite.org:443 -servername api.cmlite.org\n</code></pre></p> <p>Check certificate chain: <pre><code>openssl s_client -connect api.cmlite.org:443 -servername api.cmlite.org -showcerts\n</code></pre></p> <p>Test specific protocol: <pre><code># 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</code></pre></p>"},{"location":"v2/deployment/ssl-tls/#wildcard-certificates","title":"Wildcard Certificates","text":"<p>For <code>*.cmlite.org</code> (covers all subdomains):</p>"},{"location":"v2/deployment/ssl-tls/#lets-encrypt-dns-01-challenge","title":"Let's Encrypt (DNS-01 Challenge)","text":"<p>Required: API access to DNS provider (Cloudflare, Route53, etc.)</p> <p>Example (Cloudflare): <pre><code># 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</code></pre></p> <p>Advantage: Single certificate covers all subdomains (api, app, db, etc.).</p>"},{"location":"v2/deployment/ssl-tls/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/deployment/ssl-tls/#certificate-not-trusted","title":"Certificate Not Trusted","text":"<p>Symptoms: Browser shows \"Not Secure\" warning</p> <p>Causes: 1. Missing intermediate certificate 2. Wrong certificate file 3. Certificate expired</p> <p>Solution: <pre><code># 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</code></pre></p>"},{"location":"v2/deployment/ssl-tls/#mixed-content-warnings","title":"Mixed Content Warnings","text":"<p>Symptoms: Some assets load via HTTP on HTTPS page</p> <p>Cause: Hard-coded <code>http://</code> URLs in HTML/JS</p> <p>Solution: <pre><code>// 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</code></pre></p>"},{"location":"v2/deployment/ssl-tls/#renewal-failures","title":"Renewal Failures","text":"<p>Symptoms: Certbot renewal fails</p> <p>Diagnosis: <pre><code># Test renewal\nsudo certbot renew --dry-run\n\n# Check logs\nsudo tail -f /var/log/letsencrypt/letsencrypt.log\n</code></pre></p> <p>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)</p> <p>Solution: <pre><code># 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</code></pre></p>"},{"location":"v2/deployment/ssl-tls/#related-documentation","title":"Related Documentation","text":"<ul> <li>Docker Compose \u2014 Nginx container setup</li> <li>Nginx Configuration \u2014 Reverse proxy config</li> <li>Tunneling \u2014 Pangolin tunnel SSL</li> <li>Environment Variables \u2014 SSL-related env vars</li> </ul>"},{"location":"v2/deployment/tunneling/","title":"Pangolin Tunnel Deployment","text":""},{"location":"v2/deployment/tunneling/#overview","title":"Overview","text":"<p>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.</p> <p>Benefits: - No port forwarding needed - SSL/TLS handled by tunnel provider - Static public URLs - Self-hosted tunnel server (privacy/control) - Free/open source</p> <p>Architecture: <pre><code>Internet \u2192 Pangolin Tunnel (pangolin.bnkserve.org) \u2192 Newt Container \u2192 Nginx \u2192 Services\n</code></pre></p> <p>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</p>"},{"location":"v2/deployment/tunneling/#setup-workflow","title":"Setup Workflow","text":""},{"location":"v2/deployment/tunneling/#1-prerequisites","title":"1. Prerequisites","text":"<p>Required: - Pangolin API key (obtain from Pangolin admin) - Docker Compose running - Nginx container accessible from Newt</p> <p>Environment Variables: <pre><code>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</code></pre></p>"},{"location":"v2/deployment/tunneling/#2-setup-via-admin-gui","title":"2. Setup via Admin GUI","text":"<p>Easiest method: Use <code>/app/pangolin</code> page in admin GUI.</p> <p>Steps: 1. Navigate to http://localhost:3000/app/pangolin 2. Enter <code>PANGOLIN_API_KEY</code> (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 <code>NEWT_ID</code> and <code>NEWT_SECRET</code> to <code>.env</code> 8. Restart Newt container: <code>docker compose restart newt</code></p>"},{"location":"v2/deployment/tunneling/#3-manual-setup-cli","title":"3. Manual Setup (CLI)","text":"<p>Organization: <pre><code>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</code></pre></p> <p>Site: <pre><code>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</code></pre></p> <p>Endpoint: <pre><code>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</code></pre></p> <p>Resource (Newt Connector): <pre><code>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</code></pre></p> <p>Update .env: <pre><code>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</code></pre></p>"},{"location":"v2/deployment/tunneling/#4-start-newt-container","title":"4. Start Newt Container","text":"<pre><code># 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</code></pre>"},{"location":"v2/deployment/tunneling/#5-dns-configuration","title":"5. DNS Configuration","text":"<p>Option A: Direct Access (use tunnel URL): - https://changemaker.pangolin.bnkserve.org</p> <p>Option B: Custom Domain (CNAME to tunnel): <pre><code>app.cmlite.org. CNAME changemaker.pangolin.bnkserve.org.\napi.cmlite.org. CNAME changemaker.pangolin.bnkserve.org.\n</code></pre></p>"},{"location":"v2/deployment/tunneling/#newt-container-configuration","title":"Newt Container Configuration","text":"<p>docker-compose.yml: <pre><code>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</code></pre></p> <p>Key Features: - Restart policy: <code>unless-stopped</code> (auto-reconnects) - Nginx dependency: Ensures Nginx running before Newt starts - No ports exposed: All traffic via tunnel</p> <p>Target: Newt connects to <code>http://nginx:80</code> (Docker internal network).</p>"},{"location":"v2/deployment/tunneling/#tunnel-lifecycle","title":"Tunnel Lifecycle","text":""},{"location":"v2/deployment/tunneling/#start-tunnel","title":"Start Tunnel","text":"<pre><code>docker compose up -d newt\n</code></pre>"},{"location":"v2/deployment/tunneling/#stop-tunnel","title":"Stop Tunnel","text":"<pre><code>docker compose stop newt\n</code></pre>"},{"location":"v2/deployment/tunneling/#check-status","title":"Check Status","text":"<pre><code># 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</code></pre>"},{"location":"v2/deployment/tunneling/#restart-after-env-changes","title":"Restart (after .env changes)","text":"<pre><code>docker compose up -d --force-recreate newt\n</code></pre>"},{"location":"v2/deployment/tunneling/#exit-nodes-resource-routing","title":"Exit Nodes & Resource Routing","text":"<p>Resource: Defines how tunnel routes traffic to your services.</p> <p>Target URL: <code>http://nginx:80</code> (Nginx handles subdomain routing internally).</p> <p>Example Flow: 1. User visits <code>https://changemaker.pangolin.bnkserve.org/api/health</code> 2. Pangolin tunnel receives HTTPS request 3. Tunnel forwards to Newt container 4. Newt proxies to <code>http://nginx:80/api/health</code> 5. Nginx routes to <code>changemaker-v2-api:4000/api/health</code> 6. Response flows back through tunnel</p> <p>Multiple Resources: Create separate resources for different backends (advanced).</p>"},{"location":"v2/deployment/tunneling/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/deployment/tunneling/#tunnel-not-connecting","title":"Tunnel Not Connecting","text":"<p>Symptoms: Newt logs show connection errors</p> <p>Diagnosis: <pre><code>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</code></pre></p> <p>Solutions: <pre><code># 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</code></pre></p>"},{"location":"v2/deployment/tunneling/#tunnel-connected-but-site-unreachable","title":"Tunnel Connected But Site Unreachable","text":"<p>Symptoms: Newt connected, but HTTPS requests timeout/fail</p> <p>Diagnosis: <pre><code># 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</code></pre></p> <p>Common Causes: - Resource target points to wrong service - Nginx not listening on port 80 - Firewall blocking Nginx \u2192 backend communication</p> <p>Solution: <pre><code># 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</code></pre></p>"},{"location":"v2/deployment/tunneling/#ssl-certificate-errors","title":"SSL Certificate Errors","text":"<p>Symptoms: Browser shows \"Certificate invalid\" warning</p> <p>Cause: Tunnel endpoint SSL certificate not trusted (rare).</p> <p>Solution: Contact Pangolin support \u2014 tunnel provider manages SSL certificates.</p>"},{"location":"v2/deployment/tunneling/#frequent-disconnects","title":"Frequent Disconnects","text":"<p>Symptoms: Newt reconnects every few minutes</p> <p>Diagnosis: <pre><code># Check for network issues\ndocker compose logs newt | grep -i disconnect\n\n# Monitor connection\nwatch -n5 'docker compose logs --tail=1 newt'\n</code></pre></p> <p>Possible Causes: - Network instability - Container restarts (check <code>docker compose ps</code>) - Resource limits (check <code>docker stats newt-changemaker</code>)</p> <p>Solution: <pre><code># 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</code></pre></p>"},{"location":"v2/deployment/tunneling/#migration-from-cloudflare-tunnel","title":"Migration from Cloudflare Tunnel","text":"<p>Retired Scripts (in <code>scripts/legacy/</code>): - <code>start-production.sh</code> - <code>config.sh</code> - <code>tunnel-config.sh</code></p> <p>Migration Steps: 1. Stop Cloudflare tunnel: <code>cloudflared service uninstall</code> 2. Remove Cloudflare credentials: <code>rm ~/.cloudflared/*.json</code> 3. Setup Pangolin tunnel (see above) 4. Update DNS: Change CNAME from <code>cloudflared.com</code> to <code>pangolin.bnkserve.org</code> 5. Test new tunnel: <code>curl https://changemaker.pangolin.bnkserve.org/api/health</code> 6. Remove old scripts: <code>rm scripts/legacy/*</code></p> <p>Why Pangolin? - Self-hosted (privacy/control) - No Cloudflare dependency - Free/open source - API-driven management</p>"},{"location":"v2/deployment/tunneling/#advanced-configuration","title":"Advanced Configuration","text":""},{"location":"v2/deployment/tunneling/#custom-tunnel-domain","title":"Custom Tunnel Domain","text":"<p>Requirement: Own domain with DNS control.</p> <p>Steps: 1. Create endpoint with custom domain 2. Add DNS record: <code>tunnel.cmlite.org CNAME pangolin.bnkserve.org.</code> 3. Update <code>PANGOLIN_ENDPOINT=https://tunnel.cmlite.org</code> 4. Restart Newt</p>"},{"location":"v2/deployment/tunneling/#multiple-sites","title":"Multiple Sites","text":"<p>Use case: Staging + production on same tunnel.</p> <p>Setup: <pre><code># 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</code></pre></p>"},{"location":"v2/deployment/tunneling/#monitoring","title":"Monitoring","text":""},{"location":"v2/deployment/tunneling/#health-checks","title":"Health Checks","text":"<p>Tunnel status: <pre><code># 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</code></pre></p> <p>Prometheus metrics (if enabled): <pre><code># API uptime through tunnel\ncurl https://changemaker.pangolin.bnkserve.org/api/metrics | grep cm_api_uptime\n</code></pre></p>"},{"location":"v2/deployment/tunneling/#related-documentation","title":"Related Documentation","text":"<ul> <li>Docker Compose \u2014 Newt container configuration</li> <li>Nginx Configuration \u2014 Reverse proxy setup</li> <li>SSL/TLS \u2014 Certificate management (handled by tunnel)</li> <li>Environment Variables \u2014 Pangolin env vars</li> </ul>"},{"location":"v2/development/","title":"Development Guide","text":"<p>This section covers development workflows, local setup, coding standards, testing, and best practices for contributing to Changemaker Lite V2.</p>"},{"location":"v2/development/#development-workflow","title":"Development Workflow","text":""},{"location":"v2/development/#local-setup","title":"Local Setup","text":"<p>Getting started with local development:</p> <ul> <li>Prerequisites (Node.js, Docker, Git)</li> <li>Repository setup</li> <li>Environment configuration</li> <li>Database initialization</li> <li>Running development servers</li> </ul>"},{"location":"v2/development/#docker-workflow","title":"Docker Workflow","text":"<p>Docker-based development:</p> <ul> <li>Starting services with Docker Compose</li> <li>Viewing logs</li> <li>Rebuilding containers</li> <li>Database operations</li> <li>Volume management</li> </ul>"},{"location":"v2/development/#git-workflow","title":"Git Workflow","text":"<p>Version control best practices:</p> <ul> <li>Branch naming conventions</li> <li>Commit message format</li> <li>Pull request process</li> <li>Code review guidelines</li> <li>Merge strategies</li> </ul>"},{"location":"v2/development/#npm-commands","title":"NPM Commands","text":"<p>Common development commands:</p> <ul> <li>Development servers</li> <li>Build commands</li> <li>Testing</li> <li>Type-checking</li> <li>Linting and formatting</li> </ul>"},{"location":"v2/development/#database-migrations","title":"Database Migrations","text":"<p>Schema changes and migrations:</p> <ul> <li>Creating migrations (Prisma)</li> <li>Applying migrations</li> <li>Schema push (Drizzle)</li> <li>Rolling back changes</li> <li>Testing migrations</li> </ul>"},{"location":"v2/development/#typescript","title":"TypeScript","text":"<p>TypeScript best practices:</p> <ul> <li>Type definitions</li> <li>Strict mode</li> <li>Common patterns</li> <li>Zod integration</li> <li>Prisma types</li> </ul>"},{"location":"v2/development/#code-style","title":"Code Style","text":"<p>Coding standards and conventions:</p> <ul> <li>ESLint configuration</li> <li>Prettier formatting</li> <li>Naming conventions</li> <li>File organization</li> <li>Comment standards</li> </ul>"},{"location":"v2/development/#testing","title":"Testing","text":"<p>Testing strategies:</p> <ul> <li>Unit tests</li> <li>Integration tests</li> <li>API testing</li> <li>Component testing</li> <li>E2E testing (future)</li> </ul>"},{"location":"v2/development/#debugging","title":"Debugging","text":"<p>Debugging techniques:</p> <ul> <li>VS Code debugging</li> <li>Browser DevTools</li> <li>API debugging</li> <li>Database queries</li> <li>Log analysis</li> </ul>"},{"location":"v2/development/#quick-start","title":"Quick Start","text":""},{"location":"v2/development/#local-development-no-docker","title":"Local Development (No Docker)","text":"<p>Terminal 1: API Server <pre><code>cd api\nnpm install\nnpm run dev\n# Runs on http://localhost:4000\n</code></pre></p> <p>Terminal 2: Admin GUI <pre><code>cd admin\nnpm install\nnpm run dev\n# Runs on http://localhost:3000\n</code></pre></p> <p>Terminal 3: Media API (Optional) <pre><code>cd api\nnpm run dev:media\n# Runs on http://localhost:4100\n</code></pre></p>"},{"location":"v2/development/#docker-development","title":"Docker Development","text":"<p>Start Core Services <pre><code>docker compose up -d v2-postgres redis\ndocker compose up -d api admin\n</code></pre></p> <p>View Logs <pre><code>docker compose logs -f api\ndocker compose logs -f admin\n</code></pre></p> <p>Rebuild After Changes <pre><code>docker compose build api\ndocker compose up -d api\n</code></pre></p>"},{"location":"v2/development/#development-tools","title":"Development Tools","text":""},{"location":"v2/development/#required","title":"Required","text":"<ul> <li>Node.js 20+ - JavaScript runtime</li> <li>npm 10+ - Package manager</li> <li>Docker 24+ - Container runtime</li> <li>Docker Compose 2+ - Multi-container orchestration</li> <li>Git 2+ - Version control</li> </ul>"},{"location":"v2/development/#recommended","title":"Recommended","text":"<ul> <li>VS Code - IDE with TypeScript support</li> <li>Prisma Studio - Database GUI</li> <li>Postman - API testing</li> <li>Redis Insight - Redis GUI (optional)</li> </ul>"},{"location":"v2/development/#vs-code-extensions","title":"VS Code Extensions","text":"<ul> <li>Prisma - Schema syntax highlighting</li> <li>ESLint - JavaScript linting</li> <li>Prettier - Code formatting</li> <li>TypeScript - Language support</li> <li>Docker - Container management</li> </ul>"},{"location":"v2/development/#project-structure","title":"Project Structure","text":"<pre><code>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</code></pre>"},{"location":"v2/development/#development-patterns","title":"Development Patterns","text":""},{"location":"v2/development/#backend-module-structure","title":"Backend Module Structure","text":"<pre><code>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</code></pre>"},{"location":"v2/development/#frontend-page-structure","title":"Frontend Page Structure","text":"<pre><code>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</code></pre>"},{"location":"v2/development/#api-client-pattern","title":"API Client Pattern","text":"<pre><code>// 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</code></pre>"},{"location":"v2/development/#service-pattern","title":"Service Pattern","text":"<pre><code>// 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</code></pre>"},{"location":"v2/development/#common-tasks","title":"Common Tasks","text":""},{"location":"v2/development/#add-new-api-endpoint","title":"Add New API Endpoint","text":"<ol> <li>Create schema in <code>*.schemas.ts</code></li> <li>Add service method in <code>*.service.ts</code></li> <li>Add route handler in <code>*.routes.ts</code></li> <li>Register router in <code>server.ts</code></li> <li>Test with Postman/curl</li> </ol>"},{"location":"v2/development/#add-new-page","title":"Add New Page","text":"<ol> <li>Create page component in <code>pages/</code></li> <li>Add route in <code>App.tsx</code></li> <li>Add to sidebar menu (if admin page)</li> <li>Create API client calls</li> <li>Test in browser</li> </ol>"},{"location":"v2/development/#add-database-field","title":"Add Database Field","text":"<ol> <li>Update <code>prisma/schema.prisma</code></li> <li>Run <code>npx prisma migrate dev --name add_field</code></li> <li>Update TypeScript types</li> <li>Update API endpoints</li> <li>Update frontend forms</li> </ol>"},{"location":"v2/development/#add-new-service-integration","title":"Add New Service Integration","text":"<ol> <li>Create client in <code>services/</code></li> <li>Add environment variables</li> <li>Create admin routes</li> <li>Add admin page</li> <li>Test integration</li> </ol>"},{"location":"v2/development/#testing_1","title":"Testing","text":""},{"location":"v2/development/#api-tests","title":"API Tests","text":"<pre><code># Run API tests\ncd api && npm test\n\n# Test specific endpoint\ncurl http://localhost:4000/api/campaigns\n</code></pre>"},{"location":"v2/development/#frontend-tests","title":"Frontend Tests","text":"<pre><code># Run component tests\ncd admin && npm test\n\n# Test build\ncd admin && npm run build\n</code></pre>"},{"location":"v2/development/#type-checking","title":"Type Checking","text":"<pre><code># Check API types\ncd api && npx tsc --noEmit\n\n# Check admin types\ncd admin && npx tsc --noEmit\n</code></pre>"},{"location":"v2/development/#debugging_1","title":"Debugging","text":""},{"location":"v2/development/#api-debugging","title":"API Debugging","text":"<pre><code># 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</code></pre>"},{"location":"v2/development/#frontend-debugging","title":"Frontend Debugging","text":"<pre><code># View admin logs\ndocker compose logs -f admin\n\n# Access browser DevTools\n# Open http://localhost:3000\n# F12 for DevTools\n</code></pre>"},{"location":"v2/development/#code-quality","title":"Code Quality","text":""},{"location":"v2/development/#linting","title":"Linting","text":"<pre><code># Lint API\ncd api && npm run lint\n\n# Lint admin\ncd admin && npm run lint\n</code></pre>"},{"location":"v2/development/#formatting","title":"Formatting","text":"<pre><code># Format API\ncd api && npm run format\n\n# Format admin\ncd admin && npm run format\n</code></pre>"},{"location":"v2/development/#type-safety","title":"Type Safety","text":"<p>All code uses TypeScript with strict mode:</p> <pre><code>{\n \"compilerOptions\": {\n \"strict\": true,\n \"noImplicitAny\": true,\n \"strictNullChecks\": true\n }\n}\n</code></pre>"},{"location":"v2/development/#best-practices","title":"Best Practices","text":""},{"location":"v2/development/#backend","title":"Backend","text":"<ul> <li>Use Zod for validation</li> <li>Service layer for business logic</li> <li>Middleware for cross-cutting concerns</li> <li>Error handling with try/catch</li> <li>Logging with Winston</li> </ul>"},{"location":"v2/development/#frontend","title":"Frontend","text":"<ul> <li>Use React hooks</li> <li>Zustand for state management</li> <li>Ant Design components</li> <li>Type-safe API calls</li> <li>Error boundaries</li> </ul>"},{"location":"v2/development/#database","title":"Database","text":"<ul> <li>Use migrations for schema changes</li> <li>Index frequently queried fields</li> <li>Use transactions for multi-step operations</li> <li>Avoid N+1 queries</li> </ul>"},{"location":"v2/development/#related-documentation","title":"Related Documentation","text":"<ul> <li>Local Setup</li> <li>Docker Workflow</li> <li>Git Workflow</li> <li>NPM Commands</li> <li>Migrations</li> <li>TypeScript</li> <li>Code Style</li> <li>Testing</li> <li>Debugging</li> <li>Contributing</li> </ul>"},{"location":"v2/development/code-style/","title":"Code Style Guide","text":"<p>Coding standards and style conventions for Changemaker Lite V2.</p>"},{"location":"v2/development/code-style/#overview","title":"Overview","text":"<p>Consistent code style improves: - Readability: Easier to understand code - Maintainability: Easier to modify code - Collaboration: Reduces merge conflicts - Quality: Catches common errors</p> <p>This guide covers TypeScript, ESLint, Prettier, and naming conventions.</p>"},{"location":"v2/development/code-style/#tools","title":"Tools","text":""},{"location":"v2/development/code-style/#typescript","title":"TypeScript","text":"<p>Version: 5.x Config: <code>tsconfig.json</code> (api/ and admin/)</p> <p>Strict Mode: Enabled</p> <pre><code>{\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</code></pre>"},{"location":"v2/development/code-style/#eslint","title":"ESLint","text":"<p>Version: 8.x Config: <code>.eslintrc.js</code> (api/ and admin/)</p> <p>Plugins: - <code>@typescript-eslint/eslint-plugin</code> - <code>eslint-plugin-react</code> (admin only) - <code>eslint-plugin-react-hooks</code> (admin only)</p>"},{"location":"v2/development/code-style/#prettier","title":"Prettier","text":"<p>Version: 3.x Config: <code>.prettierrc</code></p> <p>Format on save: Enabled (VSCode)</p>"},{"location":"v2/development/code-style/#typescript-configuration","title":"TypeScript Configuration","text":""},{"location":"v2/development/code-style/#api-tsconfigjson","title":"API tsconfig.json","text":"<pre><code>{\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</code></pre>"},{"location":"v2/development/code-style/#admin-tsconfigjson","title":"Admin tsconfig.json","text":"<pre><code>{\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</code></pre>"},{"location":"v2/development/code-style/#eslint-rules","title":"ESLint Rules","text":""},{"location":"v2/development/code-style/#api-eslintrcjs","title":"API .eslintrc.js","text":"<pre><code>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</code></pre>"},{"location":"v2/development/code-style/#admin-eslintrcjs","title":"Admin .eslintrc.js","text":"<pre><code>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</code></pre>"},{"location":"v2/development/code-style/#key-rules-explained","title":"Key Rules Explained","text":"<p><code>@typescript-eslint/no-explicit-any</code> - Prevents <code>any</code> type <pre><code>// \u274c Bad\nfunction foo(data: any) {}\n\n// \u2705 Good\nfunction foo(data: User) {}\nfunction foo(data: unknown) {} // Use unknown instead\n</code></pre></p> <p><code>@typescript-eslint/no-unused-vars</code> - Prevents unused variables <pre><code>// \u274c Bad\nconst foo = 1; // Never used\n\n// \u2705 Good\nconst _foo = 1; // Prefix with _ to ignore\n</code></pre></p> <p><code>@typescript-eslint/no-floating-promises</code> - Requires await/catch <pre><code>// \u274c Bad\nasyncFunction(); // Promise not handled\n\n// \u2705 Good\nawait asyncFunction();\nasyncFunction().catch(console.error);\nvoid asyncFunction(); // Explicitly ignore\n</code></pre></p> <p><code>react-hooks/exhaustive-deps</code> - Validates useEffect dependencies <pre><code>// \u274c Bad\nuseEffect(() => {\n fetchUser(userId);\n}, []); // Missing userId dependency\n\n// \u2705 Good\nuseEffect(() => {\n fetchUser(userId);\n}, [userId]);\n</code></pre></p>"},{"location":"v2/development/code-style/#prettier-configuration","title":"Prettier Configuration","text":""},{"location":"v2/development/code-style/#prettierrc","title":".prettierrc","text":"<pre><code>{\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</code></pre>"},{"location":"v2/development/code-style/#prettierignore","title":".prettierignore","text":"<pre><code>node_modules\ndist\nbuild\ncoverage\n.vite\n.cache\n*.min.js\n*.min.css\npackage-lock.json\n</code></pre>"},{"location":"v2/development/code-style/#format-commands","title":"Format Commands","text":"<pre><code># 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</code></pre>"},{"location":"v2/development/code-style/#naming-conventions","title":"Naming Conventions","text":""},{"location":"v2/development/code-style/#files-and-directories","title":"Files and Directories","text":"<p>Files: kebab-case <pre><code>auth.service.ts\nuser.controller.ts\ncampaign.routes.ts\nlocations-page.tsx\n</code></pre></p> <p>Components: PascalCase <pre><code>UserCard.tsx\nLoginForm.tsx\nMapView.tsx\n</code></pre></p> <p>Test files: Match source file with <code>.test</code> or <code>.spec</code> <pre><code>auth.service.test.ts\nUserCard.test.tsx\n</code></pre></p> <p>Directories: kebab-case <pre><code>src/modules/auth/\nsrc/components/map/\nsrc/pages/public/\n</code></pre></p>"},{"location":"v2/development/code-style/#variables-and-functions","title":"Variables and Functions","text":"<p>Variables: camelCase <pre><code>const userName = 'John';\nconst isActive = true;\nconst totalCount = 100;\n</code></pre></p> <p>Constants: UPPER_SNAKE_CASE <pre><code>const API_URL = 'http://localhost:4000';\nconst MAX_RETRIES = 3;\nconst DEFAULT_PAGE_SIZE = 50;\n</code></pre></p> <p>Functions: camelCase <pre><code>function getUserById(id: number) {}\nasync function fetchCampaigns() {}\nconst handleClick = () => {};\n</code></pre></p> <p>Private methods: Prefix with underscore (optional) <pre><code>class UserService {\n async getUser(id: number) {}\n\n private async _hashPassword(password: string) {}\n}\n</code></pre></p>"},{"location":"v2/development/code-style/#types-and-interfaces","title":"Types and Interfaces","text":"<p>Types/Interfaces: PascalCase <pre><code>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</code></pre></p> <p>Enums: PascalCase, members UPPER_SNAKE_CASE <pre><code>enum UserRole {\n USER = 'USER',\n ADMIN = 'ADMIN',\n SUPER_ADMIN = 'SUPER_ADMIN'\n}\n</code></pre></p>"},{"location":"v2/development/code-style/#react-components","title":"React Components","text":"<p>Components: PascalCase <pre><code>export function UserCard({ user }: { user: User }) {\n return <div>{user.name}</div>;\n}\n</code></pre></p> <p>Props interfaces: ComponentNameProps <pre><code>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</code></pre></p> <p>Event handlers: handle[Event] or on[Event] <pre><code>function UserForm() {\n const handleSubmit = () => {};\n const onEmailChange = (email: string) => {};\n\n return <form onSubmit={handleSubmit}>...</form>;\n}\n</code></pre></p>"},{"location":"v2/development/code-style/#database-models","title":"Database Models","text":"<p>Prisma models: PascalCase (singular) <pre><code>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</code></pre></p> <p>Table names: snake_case (plural) <pre><code>model User {\n @@map(\"users\")\n}\n\nmodel Campaign {\n @@map(\"campaigns\")\n}\n</code></pre></p> <p>Fields: camelCase in schema, snake_case in database <pre><code>model User {\n createdAt DateTime @default(now()) @map(\"created_at\")\n updatedAt DateTime @updatedAt @map(\"updated_at\")\n}\n</code></pre></p>"},{"location":"v2/development/code-style/#file-organization","title":"File Organization","text":""},{"location":"v2/development/code-style/#module-structure","title":"Module Structure","text":"<pre><code>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</code></pre>"},{"location":"v2/development/code-style/#import-order","title":"Import Order","text":"<ol> <li>External libraries</li> <li>Internal modules (absolute imports)</li> <li>Relative imports</li> <li>Types</li> <li>Styles (frontend)</li> </ol> <pre><code>// 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</code></pre>"},{"location":"v2/development/code-style/#export-patterns","title":"Export Patterns","text":"<p>Named exports (preferred) <pre><code>// auth.service.ts\nexport class AuthService {\n async login() {}\n}\n\n// usage\nimport { AuthService } from './auth.service';\n</code></pre></p> <p>Default exports (React components) <pre><code>// UserCard.tsx\nexport default function UserCard() {\n return <div>...</div>;\n}\n\n// usage\nimport UserCard from './UserCard';\n</code></pre></p> <p>Re-exports (index files) <pre><code>// modules/auth/index.ts\nexport { AuthService } from './auth.service';\nexport { authRoutes } from './auth.routes';\nexport * from './auth.schemas';\n</code></pre></p>"},{"location":"v2/development/code-style/#code-patterns","title":"Code Patterns","text":""},{"location":"v2/development/code-style/#asyncawait","title":"Async/Await","text":"<p>Always use async/await (not callbacks or .then()):</p> <p>Good: <pre><code>async function getUser(id: number) {\n const user = await prisma.user.findUnique({ where: { id } });\n return user;\n}\n</code></pre></p> <p>Bad: <pre><code>function getUser(id: number) {\n return prisma.user.findUnique({ where: { id } }).then(user => {\n return user;\n });\n}\n</code></pre></p>"},{"location":"v2/development/code-style/#error-handling","title":"Error Handling","text":"<p>Use try/catch for error handling:</p> <p>Good: <pre><code>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</code></pre></p> <p>Bad: <pre><code>async function createUser(data: CreateUserInput) {\n const user = await prisma.user.create({ data }); // Unhandled error\n return user;\n}\n</code></pre></p>"},{"location":"v2/development/code-style/#optional-chaining","title":"Optional Chaining","text":"<p>Use optional chaining for nullable values:</p> <p>Good: <pre><code>const email = user?.email;\nconst city = user?.address?.city;\n</code></pre></p> <p>Bad: <pre><code>const email = user && user.email;\nconst city = user && user.address && user.address.city;\n</code></pre></p>"},{"location":"v2/development/code-style/#nullish-coalescing","title":"Nullish Coalescing","text":"<p>Use ?? for default values (not ||):</p> <p>Good: <pre><code>const limit = query.limit ?? 50;\nconst name = user.name ?? 'Unknown';\n</code></pre></p> <p>Bad: <pre><code>const limit = query.limit || 50; // Fails for 0\nconst name = user.name || 'Unknown'; // Fails for ''\n</code></pre></p>"},{"location":"v2/development/code-style/#array-methods","title":"Array Methods","text":"<p>Prefer functional array methods:</p> <p>Good: <pre><code>const activeUsers = users.filter(u => u.isActive);\nconst emails = users.map(u => u.email);\nconst total = amounts.reduce((sum, amt) => sum + amt, 0);\n</code></pre></p> <p>Bad: <pre><code>const activeUsers = [];\nfor (let i = 0; i < users.length; i++) {\n if (users[i].isActive) {\n activeUsers.push(users[i]);\n }\n}\n</code></pre></p>"},{"location":"v2/development/code-style/#object-destructuring","title":"Object Destructuring","text":"<p>Use destructuring for object properties:</p> <p>Good: <pre><code>const { email, name, role } = user;\nconst { limit = 50, page = 1 } = query;\n</code></pre></p> <p>Bad: <pre><code>const email = user.email;\nconst name = user.name;\nconst role = user.role;\n</code></pre></p>"},{"location":"v2/development/code-style/#template-literals","title":"Template Literals","text":"<p>Use template literals for string interpolation:</p> <p>Good: <pre><code>const message = `Hello, ${user.name}!`;\nconst url = `/api/users/${userId}`;\n</code></pre></p> <p>Bad: <pre><code>const message = 'Hello, ' + user.name + '!';\nconst url = '/api/users/' + userId;\n</code></pre></p>"},{"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":"<p>Document public functions with JSDoc:</p> <pre><code>/**\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</code></pre>"},{"location":"v2/development/code-style/#inline-comments","title":"Inline Comments","text":"<p>Use inline comments for complex logic:</p> <pre><code>// 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</code></pre>"},{"location":"v2/development/code-style/#avoid-obvious-comments","title":"Avoid Obvious Comments","text":"<p>Don't comment obvious code:</p> <p>Good: <pre><code>const isValid = email.includes('@');\n</code></pre></p> <p>Bad: <pre><code>// Check if email is valid\nconst isValid = email.includes('@');\n</code></pre></p>"},{"location":"v2/development/code-style/#todo-comments","title":"TODO Comments","text":"<p>Use TODO for future work:</p> <pre><code>// 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</code></pre>"},{"location":"v2/development/code-style/#git-commit-messages","title":"Git Commit Messages","text":""},{"location":"v2/development/code-style/#conventional-commits","title":"Conventional Commits","text":"<p>Use conventional commit format:</p> <pre><code><type>(<scope>): <subject>\n\n<body>\n\n<footer>\n</code></pre> <p>Types: - <code>feat:</code> New feature - <code>fix:</code> Bug fix - <code>docs:</code> Documentation - <code>style:</code> Formatting - <code>refactor:</code> Code restructuring - <code>test:</code> Adding tests - <code>chore:</code> Maintenance</p> <p>Examples: <pre><code>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</code></pre></p> <p>With scope and body: <pre><code>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</code></pre></p>"},{"location":"v2/development/code-style/#co-authoring-with-claude","title":"Co-Authoring with Claude","text":"<p>When Claude assists with code:</p> <pre><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</code></pre>"},{"location":"v2/development/code-style/#react-patterns","title":"React Patterns","text":""},{"location":"v2/development/code-style/#functional-components","title":"Functional Components","text":"<p>Always use functional components (not class components):</p> <p>Good: <pre><code>export function UserCard({ user }: UserCardProps) {\n return <div>{user.name}</div>;\n}\n</code></pre></p> <p>Bad: <pre><code>export class UserCard extends React.Component<UserCardProps> {\n render() {\n return <div>{this.props.user.name}</div>;\n }\n}\n</code></pre></p>"},{"location":"v2/development/code-style/#hooks","title":"Hooks","text":"<p>Use hooks for state and side effects:</p> <pre><code>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</code></pre>"},{"location":"v2/development/code-style/#props-destructuring","title":"Props Destructuring","text":"<p>Destructure props in function signature:</p> <p>Good: <pre><code>function UserCard({ user, onEdit }: UserCardProps) {\n return <div onClick={() => onEdit?.(user)}>{user.name}</div>;\n}\n</code></pre></p> <p>Bad: <pre><code>function UserCard(props: UserCardProps) {\n return <div onClick={() => props.onEdit?.(props.user)}>{props.user.name}</div>;\n}\n</code></pre></p>"},{"location":"v2/development/code-style/#key-prop","title":"Key Prop","text":"<p>Always provide key for list items:</p> <p>Good: <pre><code>{users.map(user => (\n <UserCard key={user.id} user={user} />\n))}\n</code></pre></p> <p>Bad: <pre><code>{users.map((user, index) => (\n <UserCard key={index} user={user} />\n))}\n</code></pre></p>"},{"location":"v2/development/code-style/#editor-integration","title":"Editor Integration","text":""},{"location":"v2/development/code-style/#vscode-settings","title":"VSCode Settings","text":"<p>Create <code>.vscode/settings.json</code>:</p> <pre><code>{\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</code></pre>"},{"location":"v2/development/code-style/#pre-commit-hook","title":"Pre-commit Hook","text":"<p>Install husky for pre-commit checks:</p> <pre><code>npm install --save-dev husky lint-staged\nnpx husky install\n</code></pre> <p>package.json: <pre><code>{\n \"lint-staged\": {\n \"*.{ts,tsx}\": [\n \"eslint --fix\",\n \"prettier --write\"\n ]\n },\n \"scripts\": {\n \"prepare\": \"husky install\"\n }\n}\n</code></pre></p> <p>.husky/pre-commit: <pre><code>#!/bin/sh\n. \"$(dirname \"$0\")/_/husky.sh\"\n\nnpx lint-staged\n</code></pre></p>"},{"location":"v2/development/code-style/#quick-reference","title":"Quick Reference","text":""},{"location":"v2/development/code-style/#run-linting","title":"Run Linting","text":"<pre><code># 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</code></pre>"},{"location":"v2/development/code-style/#common-fixes","title":"Common Fixes","text":"<pre><code># 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</code></pre>"},{"location":"v2/development/code-style/#related-documentation","title":"Related Documentation","text":"<ul> <li>Setup: Local Development Setup</li> <li>TypeScript: TypeScript Guide</li> <li>Testing: Testing Guide</li> <li>Git: Git Workflow</li> </ul>"},{"location":"v2/development/code-style/#summary","title":"Summary","text":"<p>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)</p> <p>Quick Start: <pre><code># 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</code></pre></p>"},{"location":"v2/development/debugging/","title":"Debugging Guide","text":"<p>Comprehensive guide to debugging Changemaker Lite V2 applications, covering API, frontend, database, and Docker debugging techniques.</p>"},{"location":"v2/development/debugging/#overview","title":"Overview","text":"<p>Effective debugging requires: - Understanding the tools (VSCode, Chrome DevTools, logs) - Systematic approach (reproduce, isolate, fix, verify) - Knowledge of common issues</p> <p>This guide covers debugging strategies for all parts of V2.</p>"},{"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":"<p>Create <code>.vscode/launch.json</code>:</p> <pre><code>{\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</code></pre>"},{"location":"v2/development/debugging/#start-debugging","title":"Start Debugging","text":"<ol> <li>Open VSCode</li> <li>Open Run and Debug panel (Cmd+Shift+D / Ctrl+Shift+D)</li> <li>Select \"Debug API\" configuration</li> <li>Press F5 to start debugging</li> <li>API starts with debugger attached</li> </ol>"},{"location":"v2/development/debugging/#set-breakpoints","title":"Set Breakpoints","text":"<p>Click line number gutter to set breakpoint:</p> <pre><code>// 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</code></pre>"},{"location":"v2/development/debugging/#debug-features","title":"Debug Features","text":"<p>Step Controls: - F10: Step over (next line) - F11: Step into (enter function) - Shift+F11: Step out (exit function) - F5: Continue (run to next breakpoint)</p> <p>Inspect Variables: - Hover over variable to see value - Use \"Variables\" panel to see all local variables - Use \"Watch\" panel to monitor specific expressions</p> <p>Debug Console: - Evaluate expressions while paused - Call functions with current scope</p> <pre><code>// 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</code></pre> <p>Call Stack: - See function call hierarchy - Click stack frame to jump to code - Useful for understanding execution flow</p>"},{"location":"v2/development/debugging/#logging-winston","title":"Logging (Winston)","text":""},{"location":"v2/development/debugging/#using-logger","title":"Using Logger","text":"<pre><code>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</code></pre>"},{"location":"v2/development/debugging/#log-output","title":"Log Output","text":"<p>Development (console): <pre><code>[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</code></pre></p> <p>Production (JSON): <pre><code>{\"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</code></pre></p>"},{"location":"v2/development/debugging/#log-levels","title":"Log Levels","text":"<p>Set log level via environment:</p> <pre><code># .env\nLOG_LEVEL=debug # dev: debug, info, warn, error\nLOG_LEVEL=info # prod: info, warn, error\n</code></pre>"},{"location":"v2/development/debugging/#database-query-logging","title":"Database Query Logging","text":""},{"location":"v2/development/debugging/#prisma-query-logging","title":"Prisma Query Logging","text":"<p>Enable in Prisma Client:</p> <pre><code>// 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</code></pre> <p>Output: <pre><code>[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</code></pre></p>"},{"location":"v2/development/debugging/#slow-query-logging","title":"Slow Query Logging","text":"<p>Log slow queries:</p> <pre><code>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</code></pre>"},{"location":"v2/development/debugging/#network-debugging","title":"Network Debugging","text":""},{"location":"v2/development/debugging/#request-logging","title":"Request Logging","text":"<p>Log all HTTP requests:</p> <pre><code>// 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</code></pre>"},{"location":"v2/development/debugging/#testing-with-curl","title":"Testing with curl","text":"<pre><code># 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</code></pre>"},{"location":"v2/development/debugging/#testing-with-httpie","title":"Testing with HTTPie","text":"<pre><code># 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</code></pre>"},{"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":"<ul> <li>F12 or Cmd+Option+I (Mac) / Ctrl+Shift+I (Windows/Linux)</li> </ul>"},{"location":"v2/development/debugging/#console-tab","title":"Console Tab","text":"<p>View console logs and errors:</p> <pre><code>// 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</code></pre> <p>Output: <pre><code>Users loaded [{ id: 1, email: 'john@example.com' }, ...]\n</code></pre></p>"},{"location":"v2/development/debugging/#sources-tab","title":"Sources Tab","text":"<p>Debug JavaScript/TypeScript:</p> <ol> <li>Open Sources tab</li> <li>Find file in file tree (webpack://./src/)</li> <li>Click line number to set breakpoint</li> <li>Interact with UI to trigger breakpoint</li> <li>Use step controls (same as VSCode)</li> </ol> <p>Conditional Breakpoints: - Right-click line number - Select \"Add conditional breakpoint\" - Enter condition: <code>user.id === 1</code> - Pauses only when condition is true</p>"},{"location":"v2/development/debugging/#network-tab","title":"Network Tab","text":"<p>Debug API calls:</p> <ol> <li>Open Network tab</li> <li>Filter by \"Fetch/XHR\"</li> <li>Interact with UI</li> <li>Click request to see:</li> <li>Headers (request/response)</li> <li>Payload (request body)</li> <li>Preview (formatted response)</li> <li>Response (raw response)</li> <li>Timing (request duration)</li> </ol> <p>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</p>"},{"location":"v2/development/debugging/#application-tab","title":"Application Tab","text":"<p>Inspect storage:</p> <ul> <li>Local Storage: See persisted auth tokens</li> <li>Session Storage: See session data</li> <li>Cookies: See cookies</li> <li>Cache Storage: See cached resources</li> </ul> <pre><code>// View in console\nlocalStorage.getItem('auth-token');\nsessionStorage.getItem('cart');\n</code></pre>"},{"location":"v2/development/debugging/#react-devtools","title":"React DevTools","text":""},{"location":"v2/development/debugging/#installation","title":"Installation","text":"<p>Install browser extension: - Chrome - Firefox</p>"},{"location":"v2/development/debugging/#components-tab","title":"Components Tab","text":"<p>Inspect React component tree:</p> <ol> <li>Open DevTools</li> <li>Go to \"Components\" tab</li> <li>Select component from tree</li> <li>View:</li> <li>Props</li> <li>State (hooks)</li> <li>Context</li> <li>Owner (parent component)</li> </ol> <p>Edit Props/State: - Click value to edit - Change takes effect immediately - Useful for testing edge cases</p>"},{"location":"v2/development/debugging/#profiler-tab","title":"Profiler Tab","text":"<p>Profile component renders:</p> <ol> <li>Go to \"Profiler\" tab</li> <li>Click \"Record\"</li> <li>Interact with UI</li> <li>Click \"Stop\"</li> <li>See:</li> <li>Flame graph (render hierarchy)</li> <li>Ranked chart (slowest components)</li> <li>Component details (render duration)</li> </ol> <p>Identify Performance Issues: - Components rendering too often - Slow component renders - Unnecessary re-renders</p>"},{"location":"v2/development/debugging/#zustand-devtools","title":"Zustand DevTools","text":""},{"location":"v2/development/debugging/#enable-redux-devtools","title":"Enable Redux DevTools","text":"<p>Already configured in stores:</p> <pre><code>// 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</code></pre>"},{"location":"v2/development/debugging/#using-redux-devtools","title":"Using Redux DevTools","text":"<ol> <li>Install Redux DevTools extension</li> <li>Open DevTools</li> <li>Go to \"Redux\" tab</li> <li>Select store from dropdown (AuthStore, CanvassStore)</li> <li>View:</li> <li>State tree</li> <li>Action history</li> <li>State diff</li> </ol> <p>Features: - Time-travel debugging (jump to previous state) - Action replay - State export/import</p>"},{"location":"v2/development/debugging/#vscode-debugging-frontend","title":"VSCode Debugging (Frontend)","text":""},{"location":"v2/development/debugging/#launch-configuration_1","title":"Launch Configuration","text":"<pre><code>{\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</code></pre> <p>Start Debugging: 1. Start Admin dev server: <code>npm run dev</code> 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</p>"},{"location":"v2/development/debugging/#database-debugging","title":"Database Debugging","text":""},{"location":"v2/development/debugging/#prisma-studio","title":"Prisma Studio","text":"<p>Visual database browser:</p> <pre><code># Start Prisma Studio\ncd api\nnpx prisma studio\n</code></pre> <p>Features: - Browse all tables - Filter and sort data - Edit records directly - Create new records - Delete records</p> <p>Use Cases: - Inspect database state - Manual data fixes - Verify migrations - Test queries</p>"},{"location":"v2/development/debugging/#postgresql-shell","title":"PostgreSQL Shell","text":"<p>Direct database access:</p> <pre><code># 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</code></pre> <p>Common Queries:</p> <pre><code>-- 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</code></pre>"},{"location":"v2/development/debugging/#query-analysis","title":"Query Analysis","text":""},{"location":"v2/development/debugging/#explain-query-plan","title":"Explain Query Plan","text":"<pre><code>EXPLAIN ANALYZE\nSELECT * FROM users WHERE email = 'admin@example.com';\n</code></pre> <p>Output: <pre><code>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</code></pre></p> <p>Identify Issues: - Sequential scans (slow on large tables) - Missing indexes - Expensive joins</p>"},{"location":"v2/development/debugging/#slow-query-log","title":"Slow Query Log","text":"<p>Enable slow query logging:</p> <pre><code>-- 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</code></pre>"},{"location":"v2/development/debugging/#docker-debugging","title":"Docker Debugging","text":""},{"location":"v2/development/debugging/#container-logs","title":"Container Logs","text":"<p>View container output:</p> <pre><code># 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</code></pre>"},{"location":"v2/development/debugging/#execute-commands-in-container","title":"Execute Commands in Container","text":"<pre><code># 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</code></pre>"},{"location":"v2/development/debugging/#inspect-container","title":"Inspect Container","text":"<pre><code># 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</code></pre>"},{"location":"v2/development/debugging/#container-stats","title":"Container Stats","text":"<pre><code># 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</code></pre>"},{"location":"v2/development/debugging/#network-debugging_1","title":"Network Debugging","text":"<pre><code># 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</code></pre>"},{"location":"v2/development/debugging/#common-issues","title":"Common Issues","text":""},{"location":"v2/development/debugging/#401-unauthorized","title":"401 Unauthorized","text":"<p>Symptoms: API returns 401 for authenticated requests.</p> <p>Causes: 1. Token expired 2. Invalid token 3. Missing Authorization header 4. Token format incorrect</p> <p>Debug:</p> <pre><code># 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</code></pre> <p>Fix: - Refresh token - Re-login - Check token format (Bearer prefix)</p>"},{"location":"v2/development/debugging/#500-internal-server-error","title":"500 Internal Server Error","text":"<p>Symptoms: API returns 500 error.</p> <p>Causes: 1. Unhandled exception 2. Database error 3. External service failure</p> <p>Debug:</p> <pre><code># 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</code></pre> <p>Fix: - Check error message in logs - Verify database is running - Check external service (Redis, SMTP, etc.)</p>"},{"location":"v2/development/debugging/#cors-errors","title":"CORS Errors","text":"<p>Symptoms: Browser blocks request with CORS error.</p> <p>Causes: 1. Incorrect CORS_ORIGIN setting 2. Missing CORS headers 3. Preflight OPTIONS request fails</p> <p>Debug:</p> <pre><code># 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</code></pre> <p>Fix: - Set <code>CORS_ORIGIN=http://localhost:3000</code> in .env - Restart API: <code>docker compose restart api</code></p>"},{"location":"v2/development/debugging/#database-connection-errors","title":"Database Connection Errors","text":"<p>Symptoms: API fails to connect to database.</p> <p>Causes: 1. PostgreSQL not running 2. Incorrect DATABASE_URL 3. Network issue</p> <p>Debug:</p> <pre><code># 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</code></pre> <p>Fix: - Start PostgreSQL: <code>docker compose up -d v2-postgres</code> - Verify DATABASE_URL matches docker-compose.yml - Check password in .env</p>"},{"location":"v2/development/debugging/#redis-connection-errors","title":"Redis Connection Errors","text":"<p>Symptoms: API fails to connect to Redis.</p> <p>Causes: 1. Redis not running 2. Incorrect REDIS_URL 3. Missing REDIS_PASSWORD</p> <p>Debug:</p> <pre><code># 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</code></pre> <p>Fix: - Start Redis: <code>docker compose up -d redis</code> - Set REDIS_PASSWORD in .env - Update REDIS_URL with password</p>"},{"location":"v2/development/debugging/#hot-reload-not-working","title":"Hot Reload Not Working","text":"<p>Symptoms: Code changes don't trigger reload.</p> <p>Causes: 1. Volume mount missing 2. File watcher not detecting changes 3. Build cache issue</p> <p>Debug:</p> <pre><code># 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</code></pre> <p>Fix: - Verify volume mount in docker-compose.yml - Restart container: <code>docker compose restart api</code> - Clear cache: <code>rm -rf api/dist && docker compose restart api</code></p>"},{"location":"v2/development/debugging/#debug-checklist","title":"Debug Checklist","text":""},{"location":"v2/development/debugging/#systematic-debugging-approach","title":"Systematic Debugging Approach","text":"<ol> <li>Reproduce:</li> <li>Can you consistently reproduce the issue?</li> <li> <p>What are the exact steps?</p> </li> <li> <p>Isolate:</p> </li> <li>Does it happen in all environments?</li> <li> <p>Is it specific to one user/data/scenario?</p> </li> <li> <p>Gather Information:</p> </li> <li>Check logs (API, frontend, database)</li> <li>Check network requests (DevTools)</li> <li> <p>Check error messages</p> </li> <li> <p>Form Hypothesis:</p> </li> <li>What do you think is causing it?</li> <li> <p>What evidence supports this?</p> </li> <li> <p>Test Hypothesis:</p> </li> <li>Set breakpoints</li> <li>Add logging</li> <li> <p>Test specific scenario</p> </li> <li> <p>Fix:</p> </li> <li>Make minimal change to fix issue</li> <li> <p>Don't fix multiple issues at once</p> </li> <li> <p>Verify:</p> </li> <li>Re-test original scenario</li> <li>Test related functionality</li> <li> <p>Check for side effects</p> </li> <li> <p>Prevent:</p> </li> <li>Add tests to catch regression</li> <li>Update documentation</li> <li>Share learnings with team</li> </ol>"},{"location":"v2/development/debugging/#performance-debugging","title":"Performance Debugging","text":""},{"location":"v2/development/debugging/#api-response-time","title":"API Response Time","text":"<pre><code>// 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</code></pre>"},{"location":"v2/development/debugging/#database-query-performance","title":"Database Query Performance","text":"<pre><code>// 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</code></pre>"},{"location":"v2/development/debugging/#frontend-render-performance","title":"Frontend Render Performance","text":"<pre><code>// 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</code></pre>"},{"location":"v2/development/debugging/#related-documentation","title":"Related Documentation","text":"<ul> <li>Setup: Local Development Setup</li> <li>Docker: Docker Workflow</li> <li>Testing: Testing Guide</li> <li>Troubleshooting: Troubleshooting Guide</li> </ul>"},{"location":"v2/development/debugging/#summary","title":"Summary","text":"<p>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</p> <p>Quick Start: <pre><code># 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</code></pre></p>"},{"location":"v2/development/docker-workflow/","title":"Docker Development Workflow","text":"<p>Guide to developing Changemaker Lite V2 using Docker containers for consistent, reproducible development environments.</p>"},{"location":"v2/development/docker-workflow/#overview","title":"Overview","text":"<p>Docker-based development provides:</p> <ul> <li>Consistency: Same environment across all developer machines</li> <li>Isolation: Services don't interfere with host system</li> <li>Production Parity: Dev environment matches production</li> <li>Easy Reset: Rebuild containers for clean state</li> </ul> <p>This guide covers Docker development workflows, from basic container operations to advanced debugging techniques.</p>"},{"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":"<p>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</p> <p>Disadvantages: - Slightly slower hot reload (especially macOS/Windows) - More complex debugging setup - Volume mount performance overhead - Larger disk space usage</p>"},{"location":"v2/development/docker-workflow/#when-to-use-local-npm","title":"When to Use Local npm","text":"<p>Advantages: - Faster hot reload (native file system) - Direct access to Node.js processes - Simpler debugging (VSCode attach) - Better performance on macOS/Windows</p> <p>Disadvantages: - Must install Node.js, PostgreSQL, Redis locally - Version inconsistencies between developers - Host system configuration required</p>"},{"location":"v2/development/docker-workflow/#hybrid-approach-recommended","title":"Hybrid Approach (Recommended)","text":"<p>Run databases in Docker, API/Admin locally:</p> <pre><code># 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</code></pre> <p>This combines benefits of both approaches.</p>"},{"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":"<p>Start all development services:</p> <pre><code># 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</code></pre> <p>Verify services started:</p> <pre><code>docker compose ps\n</code></pre> <p>Expected output: <pre><code>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</code></pre></p>"},{"location":"v2/development/docker-workflow/#selective-service-start","title":"Selective Service Start","text":"<p>Start only what you need:</p> <pre><code># 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</code></pre>"},{"location":"v2/development/docker-workflow/#start-with-monitoring-stack","title":"Start with Monitoring Stack","text":"<p>Enable monitoring services:</p> <pre><code># Start with monitoring profile\ndocker compose --profile monitoring up -d\n\n# Or specific monitoring services\ndocker compose up -d prometheus grafana\n</code></pre>"},{"location":"v2/development/docker-workflow/#watching-logs","title":"Watching Logs","text":""},{"location":"v2/development/docker-workflow/#view-service-logs","title":"View Service Logs","text":"<p>Real-time log streaming:</p> <pre><code># 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</code></pre> <p>Log output example (API): <pre><code>api | Server running on port 4000\napi | Database connected\napi | Redis connected\napi | BullMQ worker started\napi | GET /api/users 200 45ms\n</code></pre></p> <p>Log output example (Admin): <pre><code>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</code></pre></p>"},{"location":"v2/development/docker-workflow/#filter-logs","title":"Filter Logs","text":"<p>Use grep to filter log output:</p> <pre><code># 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</code></pre>"},{"location":"v2/development/docker-workflow/#export-logs","title":"Export Logs","text":"<p>Save logs to file:</p> <pre><code># 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</code></pre>"},{"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":"<p>Run commands inside running containers:</p> <pre><code># 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</code></pre>"},{"location":"v2/development/docker-workflow/#common-api-commands","title":"Common API Commands","text":"<pre><code># 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</code></pre>"},{"location":"v2/development/docker-workflow/#common-admin-commands","title":"Common Admin Commands","text":"<pre><code># 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</code></pre>"},{"location":"v2/development/docker-workflow/#database-commands","title":"Database Commands","text":"<pre><code># 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</code></pre>"},{"location":"v2/development/docker-workflow/#redis-commands","title":"Redis Commands","text":"<pre><code># 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</code></pre>"},{"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":"<p>Docker Compose volume mounts sync code between host and container:</p> <pre><code># 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</code></pre> <p>When you edit a file on host: 1. File change detected by host file system 2. Change synced to container via volume mount 3. <code>tsx watch</code> (API) or Vite (Admin) detects change 4. Service restarts (API) or HMR updates (Admin)</p>"},{"location":"v2/development/docker-workflow/#api-hot-reload","title":"API Hot Reload","text":"<p>API uses <code>tsx watch</code> for auto-restart:</p> <pre><code># 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</code></pre> <p>What triggers reload: - <code>.ts</code> file changes in <code>src/</code> - Schema changes (after Prisma migrate)</p> <p>What does NOT trigger reload: - <code>.env</code> changes (restart container manually) - <code>package.json</code> changes (rebuild container)</p>"},{"location":"v2/development/docker-workflow/#admin-hot-reload-vite-hmr","title":"Admin Hot Reload (Vite HMR)","text":"<p>Admin uses Vite Hot Module Replacement:</p> <pre><code># 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</code></pre> <p>HMR behavior: - Component changes: Updates component only - CSS changes: Updates styles instantly - Store changes: May require full reload</p>"},{"location":"v2/development/docker-workflow/#performance-considerations","title":"Performance Considerations","text":"<p>Linux: Volume mounts are native, excellent performance.</p> <p>macOS/Windows: Volume mounts use virtualization layer, slower performance.</p> <p>Optimization for macOS/Windows:</p> <ol> <li>Use delegated volume mounts (docker-compose.yml):</li> </ol> <pre><code>api:\n volumes:\n - ./api:/app:delegated # Slightly better performance\n</code></pre> <ol> <li>Reduce watched files (.dockerignore):</li> </ol> <pre><code>node_modules\ndist\ncoverage\n.git\n*.log\n</code></pre> <ol> <li>Use local development for intensive work:</li> </ol> <pre><code># Stop Docker services\ndocker compose stop api admin\n\n# Run locally\ncd api && npm run dev\ncd admin && npm run dev\n</code></pre>"},{"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":"<pre><code># 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</code></pre>"},{"location":"v2/development/docker-workflow/#seeding-database","title":"Seeding Database","text":"<pre><code># 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</code></pre>"},{"location":"v2/development/docker-workflow/#resetting-database","title":"Resetting Database","text":"<p>WARNING: Deletes all data!</p> <pre><code># 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</code></pre>"},{"location":"v2/development/docker-workflow/#prisma-studio-in-docker","title":"Prisma Studio in Docker","text":"<pre><code># Start Prisma Studio\ndocker compose exec api npx prisma studio\n\n# Access at http://localhost:5555\n</code></pre> <p>Note: Port forwarding must be configured (already set in docker-compose.yml).</p>"},{"location":"v2/development/docker-workflow/#manual-database-access","title":"Manual Database Access","text":"<pre><code># 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</code></pre>"},{"location":"v2/development/docker-workflow/#rebuilding-containers","title":"Rebuilding Containers","text":""},{"location":"v2/development/docker-workflow/#when-to-rebuild","title":"When to Rebuild","text":"<p>Rebuild containers when: - <code>package.json</code> dependencies change - <code>Dockerfile</code> changes - Base image needs update - Container is in corrupted state</p>"},{"location":"v2/development/docker-workflow/#rebuild-commands","title":"Rebuild Commands","text":"<pre><code># 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</code></pre>"},{"location":"v2/development/docker-workflow/#full-rebuild-workflow","title":"Full Rebuild Workflow","text":"<pre><code># 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</code></pre>"},{"location":"v2/development/docker-workflow/#after-package-changes","title":"After Package Changes","text":"<p>When <code>package.json</code> changes (new dependencies):</p> <pre><code># 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</code></pre>"},{"location":"v2/development/docker-workflow/#cleaning-up","title":"Cleaning Up","text":""},{"location":"v2/development/docker-workflow/#stop-services","title":"Stop Services","text":"<pre><code># 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</code></pre>"},{"location":"v2/development/docker-workflow/#remove-containers","title":"Remove Containers","text":"<pre><code># 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</code></pre>"},{"location":"v2/development/docker-workflow/#clean-docker-system","title":"Clean Docker System","text":"<pre><code># 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</code></pre>"},{"location":"v2/development/docker-workflow/#clean-project-volumes","title":"Clean Project Volumes","text":"<pre><code># 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</code></pre>"},{"location":"v2/development/docker-workflow/#reset-development-environment","title":"Reset Development Environment","text":"<p>Complete reset (deletes all data):</p> <pre><code># 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</code></pre>"},{"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":"<pre><code># 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</code></pre>"},{"location":"v2/development/docker-workflow/#inspect-container","title":"Inspect Container","text":"<pre><code># 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</code></pre>"},{"location":"v2/development/docker-workflow/#vscode-remote-containers","title":"VSCode Remote Containers","text":"<p>Install \"Remote - Containers\" extension, then:</p> <ol> <li>Open Command Palette (Cmd+Shift+P / Ctrl+Shift+P)</li> <li>Select \"Remote-Containers: Attach to Running Container\"</li> <li>Choose <code>api</code> or <code>admin</code> container</li> <li>VSCode opens new window attached to container</li> <li>Open <code>/app</code> folder in container</li> <li>Set breakpoints and debug normally</li> </ol>"},{"location":"v2/development/docker-workflow/#debug-logs","title":"Debug Logs","text":"<p>Enable verbose logging:</p> <pre><code># 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</code></pre>"},{"location":"v2/development/docker-workflow/#network-debugging","title":"Network Debugging","text":"<pre><code># 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</code></pre>"},{"location":"v2/development/docker-workflow/#performance-debugging","title":"Performance Debugging","text":"<pre><code># 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</code></pre>"},{"location":"v2/development/docker-workflow/#advanced-workflows","title":"Advanced Workflows","text":""},{"location":"v2/development/docker-workflow/#multi-stage-development","title":"Multi-Stage Development","text":"<p>Run different service combinations:</p> <pre><code># 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</code></pre>"},{"location":"v2/development/docker-workflow/#custom-docker-compose-files","title":"Custom Docker Compose Files","text":"<p>Create <code>docker-compose.dev.yml</code> for dev overrides:</p> <pre><code># 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</code></pre> <p>Usage: <pre><code># 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</code></pre></p>"},{"location":"v2/development/docker-workflow/#docker-profiles-for-optional-services","title":"Docker Profiles for Optional Services","text":"<p>Start monitoring stack:</p> <pre><code># With monitoring services\ndocker compose --profile monitoring up -d\n\n# Without monitoring (default)\ndocker compose up -d\n</code></pre> <p>Monitoring services: - Prometheus (port 9090) - Grafana (port 3001) - Alertmanager (port 9093) - cAdvisor (port 8080)</p>"},{"location":"v2/development/docker-workflow/#build-arguments","title":"Build Arguments","text":"<p>Pass build-time arguments:</p> <pre><code># 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</code></pre>"},{"location":"v2/development/docker-workflow/#health-checks","title":"Health Checks","text":"<p>Check service health:</p> <pre><code># 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</code></pre>"},{"location":"v2/development/docker-workflow/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/development/docker-workflow/#container-exits-immediately","title":"Container Exits Immediately","text":"<p>Problem: Container starts then stops.</p> <p>Solution:</p> <pre><code># 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</code></pre>"},{"location":"v2/development/docker-workflow/#volume-mount-not-working","title":"Volume Mount Not Working","text":"<p>Problem: Code changes don't appear in container.</p> <p>Solution:</p> <pre><code># 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</code></pre>"},{"location":"v2/development/docker-workflow/#permission-errors","title":"Permission Errors","text":"<p>Problem: Permission denied errors in container.</p> <p>Solution:</p> <pre><code># 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</code></pre>"},{"location":"v2/development/docker-workflow/#port-conflicts","title":"Port Conflicts","text":"<p>Problem: Port already in use.</p> <p>Solution:</p> <pre><code># 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</code></pre>"},{"location":"v2/development/docker-workflow/#database-connection-failed","title":"Database Connection Failed","text":"<p>Problem: API cannot connect to PostgreSQL.</p> <p>Solution:</p> <pre><code># 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</code></pre>"},{"location":"v2/development/docker-workflow/#out-of-disk-space","title":"Out of Disk Space","text":"<p>Problem: No space left on device.</p> <p>Solution:</p> <pre><code># 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</code></pre>"},{"location":"v2/development/docker-workflow/#container-running-out-of-memory","title":"Container Running Out of Memory","text":"<p>Problem: Container crashes with OOM.</p> <p>Solution:</p> <pre><code># 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</code></pre>"},{"location":"v2/development/docker-workflow/#slow-performance-on-macoswindows","title":"Slow Performance on macOS/Windows","text":"<p>Problem: Slow hot reload, high CPU usage.</p> <p>Solution:</p> <ol> <li>Use delegated volume mounts:</li> </ol> <pre><code>services:\n api:\n volumes:\n - ./api:/app:delegated\n</code></pre> <ol> <li>Reduce file watching:</li> </ol> <pre><code>// vite.config.ts\nexport default {\n server: {\n watch: {\n ignored: ['**/node_modules/**', '**/dist/**']\n }\n }\n}\n</code></pre> <ol> <li>Switch to local development:</li> </ol> <pre><code>docker compose up -d v2-postgres redis\ncd api && npm run dev\ncd admin && npm run dev\n</code></pre>"},{"location":"v2/development/docker-workflow/#best-practices","title":"Best Practices","text":""},{"location":"v2/development/docker-workflow/#development-workflow","title":"Development Workflow","text":"<ol> <li> <p>Start services in background: <pre><code>docker compose up -d api admin\n</code></pre></p> </li> <li> <p>Watch logs in separate terminal: <pre><code>docker compose logs -f api admin\n</code></pre></p> </li> <li> <p>Make code changes:</p> </li> <li> <p>Hot reload picks up changes automatically</p> </li> <li> <p>Type-check before commit: <pre><code>docker compose exec api npm run type-check\ndocker compose exec admin npm run type-check\n</code></pre></p> </li> <li> <p>Stop services when done: <pre><code>docker compose stop\n</code></pre></p> </li> </ol>"},{"location":"v2/development/docker-workflow/#container-naming","title":"Container Naming","text":"<p>Use meaningful service names in docker-compose.yml:</p> <pre><code>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</code></pre>"},{"location":"v2/development/docker-workflow/#environment-variables","title":"Environment Variables","text":"<ol> <li> <p>Use .env file (not docker-compose.yml): <pre><code># .env\nAPI_PORT=4000\nADMIN_PORT=3000\n</code></pre></p> </li> <li> <p>Reference in docker-compose.yml: <pre><code>services:\n api:\n environment:\n - API_PORT=${API_PORT}\n</code></pre></p> </li> <li> <p>Don't commit .env (use .env.example).</p> </li> </ol>"},{"location":"v2/development/docker-workflow/#volume-management","title":"Volume Management","text":"<ol> <li> <p>Named volumes for data: <pre><code>volumes:\n v2-postgres-data: # Persistent database\n</code></pre></p> </li> <li> <p>Bind mounts for code: <pre><code>volumes:\n - ./api:/app # Live code sync\n</code></pre></p> </li> <li> <p>Anonymous volumes for dependencies: <pre><code>volumes:\n - /app/node_modules # Isolate from host\n</code></pre></p> </li> </ol>"},{"location":"v2/development/docker-workflow/#log-management","title":"Log Management","text":"<ol> <li> <p>Use log rotation: <pre><code>services:\n api:\n logging:\n driver: \"json-file\"\n options:\n max-size: \"10m\"\n max-file: \"3\"\n</code></pre></p> </li> <li> <p>Filter logs with grep: <pre><code>docker compose logs -f api | grep ERROR\n</code></pre></p> </li> <li> <p>Export logs for analysis: <pre><code>docker compose logs > debug-logs.txt\n</code></pre></p> </li> </ol>"},{"location":"v2/development/docker-workflow/#quick-reference","title":"Quick Reference","text":""},{"location":"v2/development/docker-workflow/#essential-commands","title":"Essential Commands","text":"<pre><code># 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</code></pre>"},{"location":"v2/development/docker-workflow/#service-health-checks","title":"Service Health Checks","text":"<pre><code># 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</code></pre>"},{"location":"v2/development/docker-workflow/#quick-reset","title":"Quick Reset","text":"<pre><code># 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</code></pre>"},{"location":"v2/development/docker-workflow/#related-documentation","title":"Related Documentation","text":"<ul> <li>Setup: Local Development Setup</li> <li>Commands: NPM Commands Reference</li> <li>Database: Migrations Guide</li> <li>Debugging: Debugging Guide</li> <li>Deployment: Docker Compose Deployment</li> </ul>"},{"location":"v2/development/docker-workflow/#summary","title":"Summary","text":"<p>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</p> <p>Quick Start: <pre><code>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</code></pre></p>"},{"location":"v2/development/git-workflow/","title":"Git Workflow","text":"<p>Git branching strategy, commit conventions, and version control best practices for Changemaker Lite V2.</p>"},{"location":"v2/development/git-workflow/#overview","title":"Overview","text":"<p>Changemaker Lite V2 uses Git for version control with a structured branching strategy and conventional commit messages.</p> <p>Key Principles: - Main branch always deployable - Feature branches for new work - Descriptive commit messages - Code review via pull requests - No direct commits to main</p>"},{"location":"v2/development/git-workflow/#branch-structure","title":"Branch Structure","text":""},{"location":"v2/development/git-workflow/#main-branches","title":"Main Branches","text":"<p><code>main</code> - Production branch - Always deployable - Protected (no direct pushes) - Merges only via pull request - Tagged with version numbers</p> <p><code>v2</code> - Development branch - Active development happens here - Features merge into v2 first - Tested before merging to main - Currently the primary development branch</p>"},{"location":"v2/development/git-workflow/#feature-branches","title":"Feature Branches","text":"<p>Naming: <code>feature/<descriptive-name></code></p> <pre><code># 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</code></pre> <p>Examples: - <code>feature/add-user-avatar</code> - <code>feature/email-queue-monitoring</code> - <code>feature/map-clustering</code> - <code>feature/campaign-analytics</code></p>"},{"location":"v2/development/git-workflow/#bugfix-branches","title":"Bugfix Branches","text":"<p>Naming: <code>fix/<descriptive-name></code></p> <pre><code># 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</code></pre> <p>Examples: - <code>fix/login-redirect-loop</code> - <code>fix/map-marker-position</code> - <code>fix/email-template-rendering</code></p>"},{"location":"v2/development/git-workflow/#hotfix-branches","title":"Hotfix Branches","text":"<p>Naming: <code>hotfix/<descriptive-name></code></p> <p>For urgent production fixes:</p> <pre><code># 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</code></pre> <p>Examples: - <code>hotfix/security-patch</code> - <code>hotfix/critical-database-error</code></p>"},{"location":"v2/development/git-workflow/#release-branches","title":"Release Branches","text":"<p>Naming: <code>release/vX.Y.Z</code></p> <p>For preparing releases:</p> <pre><code># 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</code></pre>"},{"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":"<pre><code># 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</code></pre>"},{"location":"v2/development/git-workflow/#step-2-make-changes","title":"Step 2: Make Changes","text":"<p>Edit files, test locally:</p> <pre><code># 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</code></pre>"},{"location":"v2/development/git-workflow/#step-3-stage-and-commit","title":"Step 3: Stage and Commit","text":"<pre><code># 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</code></pre>"},{"location":"v2/development/git-workflow/#step-4-push-to-remote","title":"Step 4: Push to Remote","text":"<pre><code># Push branch (first time)\ngit push -u origin feature/add-user-avatar\n\n# Push subsequent commits\ngit push\n</code></pre>"},{"location":"v2/development/git-workflow/#step-5-create-pull-request","title":"Step 5: Create Pull Request","text":"<p>On GitHub/GitLab:</p> <ol> <li>Navigate to repository</li> <li>Click \"New Pull Request\"</li> <li>Select base: <code>v2</code>, compare: <code>feature/add-user-avatar</code></li> <li>Fill in PR template (title, description, testing steps)</li> <li>Request reviewers</li> <li>Link related issues</li> </ol>"},{"location":"v2/development/git-workflow/#step-6-address-review-feedback","title":"Step 6: Address Review Feedback","text":"<pre><code># 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</code></pre>"},{"location":"v2/development/git-workflow/#step-7-merge-after-approval","title":"Step 7: Merge (After Approval)","text":"<p>Squash and Merge (recommended): - Combines all commits into one - Clean history on v2 branch - Preserves individual commits in branch</p> <p>Merge Commit: - Preserves all commits - More detailed history - Use for large features</p> <p>Rebase and Merge: - Linear history - No merge commits - Use when v2 has diverged</p>"},{"location":"v2/development/git-workflow/#step-8-clean-up","title":"Step 8: Clean Up","text":"<pre><code># 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</code></pre>"},{"location":"v2/development/git-workflow/#commit-messages","title":"Commit Messages","text":""},{"location":"v2/development/git-workflow/#conventional-commits-format","title":"Conventional Commits Format","text":"<pre><code><type>(<scope>): <subject>\n\n<body>\n\n<footer>\n</code></pre>"},{"location":"v2/development/git-workflow/#types","title":"Types","text":"<ul> <li>feat: New feature</li> <li>fix: Bug fix</li> <li>docs: Documentation changes</li> <li>style: Code formatting (no logic change)</li> <li>refactor: Code restructuring (no behavior change)</li> <li>perf: Performance improvement</li> <li>test: Adding tests</li> <li>chore: Maintenance (dependencies, config)</li> <li>ci: CI/CD changes</li> <li>build: Build system changes</li> </ul>"},{"location":"v2/development/git-workflow/#scopes","title":"Scopes","text":"<p>Use module/area name:</p> <ul> <li><code>auth</code> - Authentication</li> <li><code>users</code> - User management</li> <li><code>campaigns</code> - Campaign module</li> <li><code>map</code> - Map features</li> <li><code>email</code> - Email system</li> <li><code>db</code> - Database changes</li> <li><code>ui</code> - UI components</li> <li><code>api</code> - API changes</li> </ul>"},{"location":"v2/development/git-workflow/#examples","title":"Examples","text":"<p>Simple commit: <pre><code>git commit -m \"feat(auth): add JWT refresh token rotation\"\n</code></pre></p> <p>With body: <pre><code>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</code></pre></p> <p>Breaking change: <pre><code>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</code></pre></p> <p>Multiple changes: <pre><code>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</code></pre></p> <p>Hotfix: <pre><code>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</code></pre></p>"},{"location":"v2/development/git-workflow/#git-safety-protocol","title":"Git Safety Protocol","text":"<p>From CLAUDE.md - Critical Rules:</p>"},{"location":"v2/development/git-workflow/#never-do-these-unless-user-explicitly-requests","title":"NEVER Do These (Unless User Explicitly Requests)","text":"<pre><code># \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</code></pre>"},{"location":"v2/development/git-workflow/#always-do-these","title":"ALWAYS Do These","text":"<pre><code># \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</code></pre>"},{"location":"v2/development/git-workflow/#commit-co-authoring-claude-code","title":"Commit Co-Authoring (Claude Code)","text":"<p>When Claude assists with code, add co-author:</p> <pre><code>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</code></pre> <p>Or use heredoc: <pre><code>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</code></pre></p>"},{"location":"v2/development/git-workflow/#pull-request-process","title":"Pull Request Process","text":""},{"location":"v2/development/git-workflow/#pr-template","title":"PR Template","text":"<p>Create <code>.github/pull_request_template.md</code>:</p> <pre><code>## 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</code></pre>"},{"location":"v2/development/git-workflow/#code-review-checklist","title":"Code Review Checklist","text":"<p>Reviewer checks:</p> <ul> <li> Code matches description</li> <li> Logic is correct</li> <li> Error handling present</li> <li> Tests included</li> <li> TypeScript types correct</li> <li> No security issues</li> <li> No performance issues</li> <li> Documentation updated</li> <li> Follows code style guide</li> <li> No debugging code left</li> </ul>"},{"location":"v2/development/git-workflow/#review-process","title":"Review Process","text":"<ol> <li>Author submits PR</li> <li>Fills out template</li> <li>Self-reviews changes</li> <li> <p>Requests reviewers</p> </li> <li> <p>Reviewers review</p> </li> <li>Read description</li> <li>Review code changes</li> <li>Test locally (if needed)</li> <li> <p>Leave comments/suggestions</p> </li> <li> <p>Author addresses feedback</p> </li> <li>Makes requested changes</li> <li>Responds to comments</li> <li> <p>Re-requests review</p> </li> <li> <p>Final approval</p> </li> <li>Reviewers approve</li> <li>CI/CD checks pass</li> <li>Merge to base branch</li> </ol>"},{"location":"v2/development/git-workflow/#merge-strategies","title":"Merge Strategies","text":""},{"location":"v2/development/git-workflow/#squash-and-merge-recommended","title":"Squash and Merge (Recommended)","text":"<p>When to use: - Feature branches with multiple commits - Want clean history on main/v2 - Individual commits not important</p> <p>Result: <pre><code>v2: A---B---C---D\n \\\nfeature: E---F---G (squashed into D)\n</code></pre></p> <p>How: <pre><code># 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</code></pre></p>"},{"location":"v2/development/git-workflow/#merge-commit","title":"Merge Commit","text":"<p>When to use: - Want to preserve all commits - Large features with meaningful commit history - Release branches</p> <p>Result: <pre><code>v2: A---B-------D\n \\ /\nfeature: E---F\n</code></pre></p> <p>How: <pre><code># On GitHub: \"Create a merge commit\" button\n\n# Manual:\ngit checkout v2\ngit merge feature/add-avatar\ngit push origin v2\n</code></pre></p>"},{"location":"v2/development/git-workflow/#rebase-and-merge","title":"Rebase and Merge","text":"<p>When to use: - Want linear history - Few commits - No merge conflicts</p> <p>Result: <pre><code>v2: A---B---E'---F'\n</code></pre></p> <p>How: <pre><code># 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</code></pre></p>"},{"location":"v2/development/git-workflow/#version-tags","title":"Version Tags","text":""},{"location":"v2/development/git-workflow/#semantic-versioning","title":"Semantic Versioning","text":"<p>Format: <code>vMAJOR.MINOR.PATCH</code></p> <ul> <li>MAJOR: Breaking changes</li> <li>MINOR: New features (backward compatible)</li> <li>PATCH: Bug fixes (backward compatible)</li> </ul> <p>Examples: - <code>v2.0.0</code> - Major release (V2 launch) - <code>v2.1.0</code> - New features added - <code>v2.1.1</code> - Bug fixes - <code>v2.2.0</code> - More new features</p>"},{"location":"v2/development/git-workflow/#creating-tags","title":"Creating Tags","text":"<pre><code># 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</code></pre>"},{"location":"v2/development/git-workflow/#viewing-tags","title":"Viewing Tags","text":"<pre><code># 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</code></pre>"},{"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":"<pre><code># 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</code></pre>"},{"location":"v2/development/git-workflow/#resolve-merge-conflicts","title":"Resolve Merge Conflicts","text":"<pre><code># 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</code></pre>"},{"location":"v2/development/git-workflow/#undo-changes","title":"Undo Changes","text":"<pre><code># 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</code></pre>"},{"location":"v2/development/git-workflow/#stash-changes","title":"Stash Changes","text":"<pre><code># 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</code></pre>"},{"location":"v2/development/git-workflow/#view-history","title":"View History","text":"<pre><code># 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</code></pre>"},{"location":"v2/development/git-workflow/#compare-changes","title":"Compare Changes","text":"<pre><code># 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</code></pre>"},{"location":"v2/development/git-workflow/#git-hooks","title":"Git Hooks","text":""},{"location":"v2/development/git-workflow/#pre-commit-hook","title":"Pre-commit Hook","text":"<p>Install husky:</p> <pre><code>npm install --save-dev husky lint-staged\nnpx husky install\n</code></pre> <p>Create pre-commit hook:</p> <pre><code># .husky/pre-commit\n#!/bin/sh\n. \"$(dirname \"$0\")/_/husky.sh\"\n\n# Run lint-staged\nnpx lint-staged\n</code></pre> <p>Configure lint-staged (package.json):</p> <pre><code>{\n \"lint-staged\": {\n \"*.{ts,tsx}\": [\n \"eslint --fix\",\n \"prettier --write\"\n ],\n \"*.{json,md}\": [\n \"prettier --write\"\n ]\n }\n}\n</code></pre> <p>Hook runs automatically:</p> <pre><code>git commit -m \"feat: add feature\"\n# Runs ESLint, Prettier on staged files\n# Fails commit if errors found\n</code></pre>"},{"location":"v2/development/git-workflow/#commit-msg-hook","title":"Commit-msg Hook","text":"<p>Validate commit message format:</p> <pre><code># .husky/commit-msg\n#!/bin/sh\n. \"$(dirname \"$0\")/_/husky.sh\"\n\n# Validate conventional commit format\nnpx commitlint --edit $1\n</code></pre> <p>Install commitlint:</p> <pre><code>npm install --save-dev @commitlint/cli @commitlint/config-conventional\n</code></pre> <p>Configure (.commitlintrc.json):</p> <pre><code>{\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</code></pre>"},{"location":"v2/development/git-workflow/#gitignore","title":".gitignore","text":"<p>Project <code>.gitignore</code>:</p> <pre><code># 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</code></pre>"},{"location":"v2/development/git-workflow/#collaboration","title":"Collaboration","text":""},{"location":"v2/development/git-workflow/#forks","title":"Forks","text":"<p>Fork workflow:</p> <ol> <li>Fork repository on GitHub</li> <li> <p>Clone your fork: <pre><code>git clone https://github.com/your-username/changemaker.lite.git\n</code></pre></p> </li> <li> <p>Add upstream remote: <pre><code>git remote add upstream https://github.com/original/changemaker.lite.git\n</code></pre></p> </li> <li> <p>Create feature branch: <pre><code>git checkout -b feature/my-feature\n</code></pre></p> </li> <li> <p>Make changes, commit, push to your fork: <pre><code>git push origin feature/my-feature\n</code></pre></p> </li> <li> <p>Create pull request from your fork to upstream</p> </li> </ol>"},{"location":"v2/development/git-workflow/#sync-fork-with-upstream","title":"Sync Fork with Upstream","text":"<pre><code># 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</code></pre>"},{"location":"v2/development/git-workflow/#best-practices","title":"Best Practices","text":""},{"location":"v2/development/git-workflow/#dos","title":"Do's","text":"<ul> <li>\u2705 Pull before push</li> <li>\u2705 Write descriptive commit messages</li> <li>\u2705 Stage specific files (not <code>git add .</code>)</li> <li>\u2705 Review changes before commit (<code>git diff --staged</code>)</li> <li>\u2705 Test locally before pushing</li> <li>\u2705 Keep commits focused (one logical change)</li> <li>\u2705 Use branches for all changes</li> <li>\u2705 Delete merged branches</li> </ul>"},{"location":"v2/development/git-workflow/#donts","title":"Don'ts","text":"<ul> <li>\u274c Commit directly to main</li> <li>\u274c Force push without approval</li> <li>\u274c Commit large binary files</li> <li>\u274c Commit secrets (.env, API keys)</li> <li>\u274c Use <code>git add .</code> (stage specific files)</li> <li>\u274c Amend commits after pushing</li> <li>\u274c Rebase public branches</li> <li>\u274c Leave debugging code in commits</li> </ul>"},{"location":"v2/development/git-workflow/#related-documentation","title":"Related Documentation","text":"<ul> <li>Setup: Local Development Setup</li> <li>Code Style: Code Style Guide</li> <li>Testing: Testing Guide</li> <li>Contributing: Contributing Guide</li> </ul>"},{"location":"v2/development/git-workflow/#summary","title":"Summary","text":"<p>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</p> <p>Quick Reference: <pre><code># 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</code></pre></p>"},{"location":"v2/development/local-setup/","title":"Local Development Setup","text":"<p>This guide walks you through setting up Changemaker Lite V2 for local development on your machine.</p>"},{"location":"v2/development/local-setup/#overview","title":"Overview","text":"<p>Changemaker Lite V2 supports two development approaches:</p> <ol> <li>Docker-based development - Run API and Admin in containers (recommended for consistency)</li> <li>Local npm development - Run services directly on your host machine (faster hot reload)</li> </ol> <p>This guide covers both approaches. Choose the one that fits your workflow.</p>"},{"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":"<ul> <li>Node.js 20.x LTS or higher</li> <li>npm 10.x or higher</li> </ul> <pre><code># Check versions\nnode --version # Should be v20.x.x or higher\nnpm --version # Should be 10.x.x or higher\n</code></pre> <p>Installation: - Download from nodejs.org - Or use nvm for version management:</p> <pre><code>nvm install 20\nnvm use 20\n</code></pre>"},{"location":"v2/development/local-setup/#docker-and-docker-compose","title":"Docker and Docker Compose","text":"<ul> <li>Docker Engine 24.x or higher</li> <li>Docker Compose 2.x or higher (included with Docker Desktop)</li> </ul> <pre><code># Check versions\ndocker --version # Should be 24.x.x or higher\ndocker compose version # Should be 2.x.x or higher\n</code></pre> <p>Installation: - Docker Desktop: docker.com/get-started - Linux: docs.docker.com/engine/install</p>"},{"location":"v2/development/local-setup/#git","title":"Git","text":"<ul> <li>Git 2.30 or higher</li> </ul> <pre><code># Check version\ngit --version # Should be 2.30.x or higher\n</code></pre> <p>Installation: - Download from git-scm.com - Or use package manager (apt, brew, etc.)</p>"},{"location":"v2/development/local-setup/#optional-tools","title":"Optional Tools","text":""},{"location":"v2/development/local-setup/#postgresql-client-tools","title":"PostgreSQL Client Tools","text":"<p>Useful for database inspection and debugging:</p> <pre><code># Ubuntu/Debian\nsudo apt install postgresql-client\n\n# macOS\nbrew install postgresql@16\n\n# Check installation\npsql --version\n</code></pre>"},{"location":"v2/development/local-setup/#redis-cli","title":"Redis CLI","text":"<p>For cache/queue debugging:</p> <pre><code># Ubuntu/Debian\nsudo apt install redis-tools\n\n# macOS\nbrew install redis\n\n# Check installation\nredis-cli --version\n</code></pre>"},{"location":"v2/development/local-setup/#visual-studio-code","title":"Visual Studio Code","text":"<p>Recommended IDE with excellent TypeScript support:</p> <ul> <li>Download from code.visualstudio.com</li> <li>See IDE Setup section for recommended extensions</li> </ul>"},{"location":"v2/development/local-setup/#system-requirements","title":"System Requirements","text":"<p>Minimum: - 8 GB RAM - 20 GB free disk space - 2 CPU cores</p> <p>Recommended: - 16 GB RAM - 50 GB free disk space - 4+ CPU cores</p>"},{"location":"v2/development/local-setup/#repository-setup","title":"Repository Setup","text":""},{"location":"v2/development/local-setup/#clone-repository","title":"Clone Repository","text":"<pre><code># 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</code></pre>"},{"location":"v2/development/local-setup/#repository-structure","title":"Repository Structure","text":"<p>After cloning, your directory structure should look like:</p> <pre><code>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</code></pre>"},{"location":"v2/development/local-setup/#verify-files","title":"Verify Files","text":"<p>Check that key files exist:</p> <pre><code>ls -la api/package.json admin/package.json docker-compose.yml .env.example\n</code></pre> <p>If any files are missing, ensure you're on the <code>v2</code> branch.</p>"},{"location":"v2/development/local-setup/#environment-configuration","title":"Environment Configuration","text":""},{"location":"v2/development/local-setup/#create-env-file","title":"Create .env File","text":"<p>Copy the example environment file:</p> <pre><code>cp .env.example .env\n</code></pre>"},{"location":"v2/development/local-setup/#configure-essential-variables","title":"Configure Essential Variables","text":"<p>Open <code>.env</code> in your editor and set the following critical variables:</p>"},{"location":"v2/development/local-setup/#database-passwords","title":"Database Passwords","text":"<pre><code># 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</code></pre>"},{"location":"v2/development/local-setup/#jwt-secrets","title":"JWT Secrets","text":"<p>Generate secure random secrets:</p> <pre><code># 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</code></pre> <p>Add to <code>.env</code>:</p> <pre><code># 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</code></pre> <p>IMPORTANT: All three secrets must be different values!</p>"},{"location":"v2/development/local-setup/#email-configuration-development","title":"Email Configuration (Development)","text":"<p>For development, use MailHog to capture emails locally:</p> <pre><code># 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</code></pre>"},{"location":"v2/development/local-setup/#optional-features","title":"Optional Features","text":"<p>Enable optional features as needed:</p> <pre><code># 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</code></pre>"},{"location":"v2/development/local-setup/#complete-env-template","title":"Complete .env Template","text":"<p>Here's a minimal <code>.env</code> for local development:</p> <pre><code># 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</code></pre>"},{"location":"v2/development/local-setup/#verify-configuration","title":"Verify Configuration","text":"<p>Check that required variables are set:</p> <pre><code>grep -E '^(V2_POSTGRES_PASSWORD|REDIS_PASSWORD|JWT_ACCESS_SECRET|JWT_REFRESH_SECRET|ENCRYPTION_KEY)=' .env\n</code></pre> <p>You should see 5 lines with non-empty values.</p>"},{"location":"v2/development/local-setup/#database-setup","title":"Database Setup","text":""},{"location":"v2/development/local-setup/#start-database-services","title":"Start Database Services","text":"<p>Start PostgreSQL and Redis containers:</p> <pre><code>docker compose up -d v2-postgres redis\n</code></pre> <p>Wait for databases to initialize (first run takes 30-60 seconds):</p> <pre><code># 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</code></pre>"},{"location":"v2/development/local-setup/#verify-database-connection","title":"Verify Database Connection","text":"<p>Test PostgreSQL connection:</p> <pre><code>docker compose exec v2-postgres psql -U changemaker_v2 -d changemaker_v2_db -c \"SELECT version();\"\n</code></pre> <p>You should see PostgreSQL version information.</p> <p>Test Redis connection:</p> <pre><code>docker compose exec redis redis-cli -a your_redis_password ping\n# Output: PONG\n</code></pre>"},{"location":"v2/development/local-setup/#install-api-dependencies","title":"Install API Dependencies","text":"<pre><code>cd api\nnpm install\n</code></pre> <p>Expected output: - Installs ~300+ packages - May show peer dependency warnings (safe to ignore) - Should complete without errors</p>"},{"location":"v2/development/local-setup/#run-database-migrations","title":"Run Database Migrations","text":"<p>Apply Prisma migrations to create database schema:</p> <pre><code># From api/ directory\nnpx prisma migrate deploy\n</code></pre> <p>Expected output: <pre><code>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</code></pre></p>"},{"location":"v2/development/local-setup/#seed-database","title":"Seed Database","text":"<p>Populate database with initial data (admin user, settings, etc.):</p> <pre><code># From api/ directory\nnpx prisma db seed\n</code></pre> <p>Expected output: <pre><code>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</code></pre></p> <p>Default Admin Credentials: - Email: <code>admin@example.com</code> - Password: <code>Admin123!</code> - Change this password immediately after first login!</p>"},{"location":"v2/development/local-setup/#verify-database-schema","title":"Verify Database Schema","text":"<p>Open Prisma Studio to browse the database:</p> <pre><code># From api/ directory\nnpx prisma studio\n</code></pre> <p>This opens a browser at <code>http://localhost:5555</code> showing: - 30+ tables (User, Campaign, Location, Shift, etc.) - Seeded data (1 admin user, settings, blocks)</p> <p>Press Ctrl+C to close Prisma Studio when done.</p>"},{"location":"v2/development/local-setup/#return-to-project-root","title":"Return to Project Root","text":"<pre><code>cd .. # Back to changemaker.lite/\n</code></pre>"},{"location":"v2/development/local-setup/#starting-services","title":"Starting Services","text":"<p>You have two options for running the development servers:</p>"},{"location":"v2/development/local-setup/#option-1-docker-based-development-recommended","title":"Option 1: Docker-based Development (Recommended)","text":"<p>Run API and Admin in Docker containers with volume mounts for hot reload:</p> <pre><code># 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</code></pre> <p>Watch logs:</p> <pre><code># 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</code></pre> <p>Verify services started:</p> <pre><code>docker compose ps\n</code></pre> <p>You should see: - <code>api</code> - running on port 4000 - <code>admin</code> - running on port 3000 - <code>v2-postgres</code> - running on port 5433 - <code>redis</code> - running on port 6379 - <code>mailhog</code> - running on port 8025 (if started)</p> <p>Hot Reload in Docker:</p> <p>Volume mounts automatically sync code changes: - API: <code>tsx watch</code> restarts server on file changes - Admin: Vite HMR updates browser without full reload</p>"},{"location":"v2/development/local-setup/#option-2-local-npm-development","title":"Option 2: Local npm Development","text":"<p>Run services directly on your host machine (faster hot reload):</p>"},{"location":"v2/development/local-setup/#terminal-1-api-server","title":"Terminal 1: API Server","text":"<pre><code>cd api\nnpm run dev\n</code></pre> <p>Expected output: <pre><code>> 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</code></pre></p>"},{"location":"v2/development/local-setup/#terminal-2-admin-server","title":"Terminal 2: Admin Server","text":"<pre><code>cd admin\nnpm install # First time only\nnpm run dev\n</code></pre> <p>Expected output: <pre><code>> 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</code></pre></p>"},{"location":"v2/development/local-setup/#terminal-3-media-api-optional","title":"Terminal 3: Media API (Optional)","text":"<pre><code>cd api\nnpm run dev:media\n</code></pre> <p>Expected output: <pre><code>> api@2.0.0 dev:media\n> tsx watch src/media-server.ts\n\nMedia API server running on port 4100\nDatabase connected\n</code></pre></p>"},{"location":"v2/development/local-setup/#background-services","title":"Background Services","text":"<p>You still need Docker for PostgreSQL, Redis, and MailHog:</p> <pre><code>docker compose up -d v2-postgres redis mailhog\n</code></pre>"},{"location":"v2/development/local-setup/#which-approach-to-use","title":"Which Approach to Use?","text":"<p>Use Docker-based development if: - You want consistent environment across team - You're new to the project - You prefer simpler setup</p> <p>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</p> <p>You can mix approaches: - Run API in Docker, Admin locally - Run databases in Docker, both API/Admin locally</p>"},{"location":"v2/development/local-setup/#verifying-setup","title":"Verifying Setup","text":""},{"location":"v2/development/local-setup/#health-check-endpoints","title":"Health Check Endpoints","text":"<p>Test that services are responding:</p> <pre><code># 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</code></pre>"},{"location":"v2/development/local-setup/#test-authentication","title":"Test Authentication","text":"<p>Test login endpoint:</p> <pre><code>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</code></pre> <p>Expected response: <pre><code>{\n \"accessToken\": \"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...\",\n \"refreshToken\": \"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...\",\n \"user\": {\n \"id\": 1,\n \"email\": \"admin@example.com\",\n \"role\": \"SUPER_ADMIN\"\n }\n}\n</code></pre></p>"},{"location":"v2/development/local-setup/#login-to-admin-gui","title":"Login to Admin GUI","text":"<ol> <li>Open http://localhost:3000 in browser</li> <li>Login with:</li> <li>Email: <code>admin@example.com</code></li> <li>Password: <code>Admin123!</code></li> <li>You should be redirected to <code>/app</code> (admin dashboard)</li> <li>Change password immediately:</li> <li>Click user menu (top right)</li> <li>Settings \u2192 Change Password</li> <li>Set new password (12+ chars, uppercase, lowercase, digit)</li> </ol>"},{"location":"v2/development/local-setup/#verify-database-connection_1","title":"Verify Database Connection","text":"<p>Check that API can query database:</p> <pre><code>curl http://localhost:4000/api/users \\\n -H \"Authorization: Bearer <access_token_from_login>\"\n</code></pre> <p>Expected response: <pre><code>{\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</code></pre></p>"},{"location":"v2/development/local-setup/#test-email-capture-mailhog","title":"Test Email Capture (MailHog)","text":"<ol> <li>Open http://localhost:8025 in browser</li> <li>You should see MailHog web UI</li> <li>Trigger a test email (e.g., shift signup)</li> <li>Email appears in MailHog inbox</li> </ol>"},{"location":"v2/development/local-setup/#ide-setup","title":"IDE Setup","text":""},{"location":"v2/development/local-setup/#visual-studio-code_1","title":"Visual Studio Code","text":"<p>Recommended IDE with excellent TypeScript/React support.</p>"},{"location":"v2/development/local-setup/#recommended-extensions","title":"Recommended Extensions","text":"<p>Install these extensions for best developer experience:</p> <p>Essential: - ESLint (<code>dbaeumer.vscode-eslint</code>) - Linting - Prettier (<code>esbenp.prettier-vscode</code>) - Code formatting - Prisma (<code>Prisma.prisma</code>) - Prisma schema support - TypeScript Vue Plugin (Volar) (<code>Vue.volar</code>) - Vue/JSX support</p> <p>Highly Recommended: - GitLens (<code>eamodio.gitlens</code>) - Git insights - Docker (<code>ms-azuretools.vscode-docker</code>) - Docker management - Thunder Client (<code>rangav.vscode-thunder-client</code>) - API testing - Error Lens (<code>usernamehw.errorlens</code>) - Inline errors - Auto Rename Tag (<code>formulahendry.auto-rename-tag</code>) - HTML/JSX tag pairs - Path Intellisense (<code>christian-kohler.path-intellisense</code>) - Path autocomplete</p> <p>Optional: - Tailwind CSS IntelliSense (<code>bradlc.vscode-tailwindcss</code>) - Tailwind support - DotENV (<code>mikestead.dotenv</code>) - .env syntax highlighting - Import Cost (<code>wix.vscode-import-cost</code>) - Bundle size info</p>"},{"location":"v2/development/local-setup/#workspace-settings","title":"Workspace Settings","text":"<p>Create <code>.vscode/settings.json</code> in project root:</p> <pre><code>{\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</code></pre>"},{"location":"v2/development/local-setup/#launch-configuration","title":"Launch Configuration","text":"<p>Create <code>.vscode/launch.json</code> for debugging:</p> <pre><code>{\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</code></pre>"},{"location":"v2/development/local-setup/#workspace-file","title":"Workspace File","text":"<p>Create <code>changemaker-lite.code-workspace</code>:</p> <pre><code>{\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</code></pre> <p>Open workspace: <code>code changemaker-lite.code-workspace</code></p>"},{"location":"v2/development/local-setup/#other-ides","title":"Other IDEs","text":""},{"location":"v2/development/local-setup/#webstorm-intellij-idea","title":"WebStorm / IntelliJ IDEA","text":"<ul> <li>Built-in TypeScript support</li> <li>Built-in Prisma plugin</li> <li>Configure ESLint/Prettier in Preferences \u2192 Languages & Frameworks</li> </ul>"},{"location":"v2/development/local-setup/#neovim-vim","title":"Neovim / Vim","text":"<ul> <li>Use LSP with <code>typescript-language-server</code></li> <li>Prisma LSP: <code>@prisma/language-server</code></li> <li>ESLint/Prettier via null-ls or ALE</li> </ul>"},{"location":"v2/development/local-setup/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/development/local-setup/#port-conflicts","title":"Port Conflicts","text":"<p>Problem: Port already in use errors</p> <pre><code>Error: listen EADDRINUSE: address already in use :::4000\n</code></pre> <p>Solution 1: Find and kill the process using the port</p> <pre><code># Linux/macOS\nlsof -ti:4000 | xargs kill -9\n\n# Or change port in .env\nAPI_PORT=4002\n</code></pre> <p>Solution 2: Use different ports in <code>.env</code></p> <pre><code>API_PORT=4002\nADMIN_PORT=3002\nMEDIA_API_PORT=4102\n</code></pre>"},{"location":"v2/development/local-setup/#database-connection-errors","title":"Database Connection Errors","text":"<p>Problem: API cannot connect to PostgreSQL</p> <pre><code>Error: connect ECONNREFUSED 127.0.0.1:5433\n</code></pre> <p>Solution 1: Verify PostgreSQL is running</p> <pre><code>docker compose ps v2-postgres\n# Should show \"running\"\n</code></pre> <p>Solution 2: Check DATABASE_URL in .env</p> <pre><code># Should match your password and port\nDATABASE_URL=postgresql://changemaker_v2:your_password@localhost:5433/changemaker_v2_db\n</code></pre> <p>Solution 3: Restart PostgreSQL container</p> <pre><code>docker compose restart v2-postgres\ndocker compose logs -f v2-postgres\n# Wait for \"ready to accept connections\"\n</code></pre>"},{"location":"v2/development/local-setup/#redis-connection-errors","title":"Redis Connection Errors","text":"<p>Problem: API cannot connect to Redis</p> <pre><code>Error: Redis connection refused\n</code></pre> <p>Solution 1: Verify Redis is running</p> <pre><code>docker compose ps redis\n# Should show \"running\"\n</code></pre> <p>Solution 2: Check REDIS_URL and password</p> <pre><code># Should match your password\nREDIS_URL=redis://:your_redis_password@localhost:6379\nREDIS_PASSWORD=your_redis_password\n</code></pre> <p>Solution 3: Test Redis connection directly</p> <pre><code>docker compose exec redis redis-cli -a your_redis_password ping\n# Should output: PONG\n</code></pre>"},{"location":"v2/development/local-setup/#migration-errors","title":"Migration Errors","text":"<p>Problem: Prisma migration fails</p> <pre><code>Error: P3005 Database schema is not empty\n</code></pre> <p>Solution 1: Reset database (DEVELOPMENT ONLY)</p> <pre><code>cd api\nnpx prisma migrate reset\n# WARNING: This deletes all data!\n</code></pre> <p>Solution 2: Force deploy migrations</p> <pre><code>cd api\nnpx prisma migrate deploy --force\n</code></pre> <p>Solution 3: Check migration history</p> <pre><code>cd api\nnpx prisma migrate status\n</code></pre>"},{"location":"v2/development/local-setup/#npm-install-failures","title":"npm Install Failures","text":"<p>Problem: npm install fails with permission errors</p> <p>Solution 1: Clear npm cache</p> <pre><code>npm cache clean --force\nrm -rf node_modules package-lock.json\nnpm install\n</code></pre> <p>Solution 2: Use correct Node.js version</p> <pre><code>node --version # Should be v20.x.x\nnvm use 20\n</code></pre> <p>Solution 3: Check disk space</p> <pre><code>df -h\n# Ensure sufficient space (10GB+ free)\n</code></pre>"},{"location":"v2/development/local-setup/#hot-reload-not-working","title":"Hot Reload Not Working","text":"<p>Problem: Code changes don't trigger reload</p> <p>Solution 1 (Docker): Verify volume mounts in docker-compose.yml</p> <pre><code>api:\n volumes:\n - ./api:/app # Must be present\n</code></pre> <p>Solution 2 (Local): Restart dev server</p> <pre><code># Stop (Ctrl+C) and restart\nnpm run dev\n</code></pre> <p>Solution 3 (Admin/Vite): Clear Vite cache</p> <pre><code>cd admin\nrm -rf node_modules/.vite\nnpm run dev\n</code></pre>"},{"location":"v2/development/local-setup/#admin-build-errors","title":"Admin Build Errors","text":"<p>Problem: TypeScript errors on build</p> <pre><code>error TS2339: Property 'foo' does not exist on type 'Bar'\n</code></pre> <p>Solution 1: Type-check without emit</p> <pre><code>cd admin\nnpx tsc --noEmit\n# Shows all type errors\n</code></pre> <p>Solution 2: Update type definitions</p> <pre><code>cd admin\nnpm install --save-dev @types/react@latest @types/react-dom@latest\n</code></pre> <p>Solution 3: Check tsconfig.json</p> <pre><code>cd admin\ncat tsconfig.json\n# Ensure \"strict\": true and \"skipLibCheck\": false\n</code></pre>"},{"location":"v2/development/local-setup/#docker-container-crashes","title":"Docker Container Crashes","text":"<p>Problem: API/Admin container exits immediately</p> <p>Solution 1: Check logs</p> <pre><code>docker compose logs api\n# Look for error messages\n</code></pre> <p>Solution 2: Verify .env file exists</p> <pre><code>ls -la .env\n# Should exist in project root\n</code></pre> <p>Solution 3: Rebuild containers</p> <pre><code>docker compose down\ndocker compose build --no-cache api admin\ndocker compose up -d api admin\n</code></pre>"},{"location":"v2/development/local-setup/#browser-cors-errors","title":"Browser CORS Errors","text":"<p>Problem: Admin cannot call API (CORS errors in browser console)</p> <p>Solution 1: Check CORS_ORIGIN in .env</p> <pre><code># For local development\nCORS_ORIGIN=http://localhost:3000\n</code></pre> <p>Solution 2: Verify API_URL in admin</p> <p>For Docker-based API, admin vite.config.ts proxy should work automatically.</p> <p>For local API, ensure <code>VITE_API_URL</code> is NOT set (defaults to localhost:4000).</p> <p>Solution 3: Clear browser cache</p> <ul> <li>Open DevTools \u2192 Network tab \u2192 Disable cache</li> <li>Hard reload (Cmd+Shift+R / Ctrl+Shift+R)</li> </ul>"},{"location":"v2/development/local-setup/#hot-reload","title":"Hot Reload","text":""},{"location":"v2/development/local-setup/#api-hot-reload-tsx-watch","title":"API Hot Reload (tsx watch)","text":"<p>API uses <code>tsx watch</code> for automatic restart on file changes:</p> <pre><code># Started automatically with npm run dev\ncd api\nnpm run dev\n</code></pre> <p>What triggers reload: - Changes to <code>.ts</code> files in <code>src/</code> - Changes to <code>.prisma</code> files (after running migrate)</p> <p>What does NOT trigger reload: - Changes to <code>.env</code> (restart manually) - Changes to <code>node_modules/</code> (reinstall packages)</p> <p>Manual restart: <pre><code># If using npm run dev, just Ctrl+C and restart\nnpm run dev\n</code></pre></p>"},{"location":"v2/development/local-setup/#admin-hot-reload-vite-hmr","title":"Admin Hot Reload (Vite HMR)","text":"<p>Admin uses Vite's Hot Module Replacement (HMR):</p> <pre><code>cd admin\nnpm run dev\n</code></pre> <p>What triggers HMR: - Changes to <code>.tsx</code> / <code>.ts</code> files - Changes to <code>.css</code> files - Changes to imported assets</p> <p>HMR Behavior: - Component changes: Updates without full reload - Hook changes: May require full reload - Route changes: Full reload</p> <p>Force full reload: - Press <code>r</code> in terminal running Vite - Or refresh browser (Cmd+R / Ctrl+R)</p>"},{"location":"v2/development/local-setup/#docker-hot-reload","title":"Docker Hot Reload","text":"<p>Docker volume mounts enable hot reload in containers:</p> <pre><code># docker-compose.yml\napi:\n volumes:\n - ./api:/app # Syncs code changes\n - /app/node_modules # Preserves container's node_modules\n</code></pre> <p>Same reload behavior as local: - API: tsx watch restarts on .ts changes - Admin: Vite HMR updates browser</p> <p>Performance note: - macOS/Windows: Volume mounts slightly slower than Linux - For intensive development, consider running locally instead</p>"},{"location":"v2/development/local-setup/#debugging","title":"Debugging","text":""},{"location":"v2/development/local-setup/#api-debugging-vscode","title":"API Debugging (VSCode)","text":"<ol> <li>Open VSCode</li> <li>Open Run and Debug panel (Cmd+Shift+D / Ctrl+Shift+D)</li> <li>Select \"Debug API\" configuration</li> <li>Press F5 to start debugging</li> <li>Set breakpoints by clicking line numbers</li> <li>Trigger API endpoint to hit breakpoint</li> </ol> <p>Debugging features: - Step through code (F10, F11) - Inspect variables - Evaluate expressions in Debug Console - Call stack navigation</p>"},{"location":"v2/development/local-setup/#frontend-debugging-chrome-devtools","title":"Frontend Debugging (Chrome DevTools)","text":"<ol> <li>Open Admin in Chrome: http://localhost:3000</li> <li>Open DevTools (F12 / Cmd+Option+I)</li> <li>Go to Sources tab</li> <li>Find your component in file tree (webpack://./src/)</li> <li>Set breakpoints by clicking line numbers</li> <li>Interact with UI to trigger breakpoint</li> </ol> <p>React DevTools: - Install React DevTools browser extension - Inspect component tree - View/edit props and state - Profile component renders</p>"},{"location":"v2/development/local-setup/#zustand-devtools","title":"Zustand DevTools","text":"<p>Enable Redux DevTools for Zustand stores:</p> <pre><code>// 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</code></pre> <p>Usage: 1. Install Redux DevTools browser extension 2. Open extension 3. Select \"AuthStore\" or \"CanvassStore\" 4. See action history and state changes</p>"},{"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":"<pre><code># 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</code></pre>"},{"location":"v2/development/local-setup/#feature-development-workflow","title":"Feature Development Workflow","text":"<pre><code># 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</code></pre>"},{"location":"v2/development/local-setup/#database-schema-changes","title":"Database Schema Changes","text":"<pre><code># 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</code></pre>"},{"location":"v2/development/local-setup/#bug-fixing-workflow","title":"Bug Fixing Workflow","text":"<pre><code># 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</code></pre>"},{"location":"v2/development/local-setup/#switching-between-docker-and-local","title":"Switching Between Docker and Local","text":"<p>From Docker to Local:</p> <pre><code># 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</code></pre> <p>From Local to Docker:</p> <pre><code># 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</code></pre>"},{"location":"v2/development/local-setup/#next-steps","title":"Next Steps","text":"<p>After completing local setup:</p> <ol> <li>Read Development Guides:</li> <li>NPM Commands Reference - All package.json scripts</li> <li>Docker Workflow - Advanced Docker development</li> <li> <p>Database Migrations - Schema change workflow</p> </li> <li> <p>Understand Architecture:</p> </li> <li>API Architecture - Backend organization</li> <li>Frontend Architecture - React app structure</li> <li> <p>Database Schema - Data models</p> </li> <li> <p>Learn Code Patterns:</p> </li> <li>TypeScript Guide - TypeScript best practices</li> <li>Code Style Guide - Coding standards</li> <li> <p>Testing Guide - Test writing</p> </li> <li> <p>Start Contributing:</p> </li> <li>Git Workflow - Branching and commits</li> <li>Contributing Guide - Contribution process</li> <li>V2 Development Plan - Roadmap and phases</li> </ol>"},{"location":"v2/development/local-setup/#related-documentation","title":"Related Documentation","text":"<ul> <li>Deployment: Docker Compose Deployment</li> <li>Configuration: Environment Variables</li> <li>Database: Migrations Guide</li> <li>Testing: Testing Strategy</li> <li>Debugging: Debugging Guide</li> </ul>"},{"location":"v2/development/local-setup/#getting-help","title":"Getting Help","text":"<p>Documentation: - This guide for setup issues - Troubleshooting for common problems - FAQ for quick answers</p> <p>Community: - GitHub Issues for bug reports - GitHub Discussions for questions - Project README for contact info</p> <p>Logs: - API logs: <code>docker compose logs -f api</code> - Admin logs: <code>docker compose logs -f admin</code> - Database logs: <code>docker compose logs -f v2-postgres</code></p>"},{"location":"v2/development/local-setup/#summary","title":"Summary","text":"<p>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)</p> <p>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</p> <p>Ready to develop! Choose a task from V2_PLAN.md Phase 15 or create a feature branch.</p>"},{"location":"v2/development/migrations/","title":"Database Migrations Guide","text":"<p>Complete guide to managing database schema changes in Changemaker Lite V2 using Prisma Migrate and Drizzle Kit.</p>"},{"location":"v2/development/migrations/#overview","title":"Overview","text":"<p>Changemaker Lite V2 uses two ORMs for different parts of the application:</p> <ul> <li>Prisma (Main API) - Full-featured ORM with migration tracking</li> <li>Drizzle (Media API) - Lightweight ORM with schema push (no migrations)</li> </ul> <p>This guide covers both workflows.</p>"},{"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":"<pre><code>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</code></pre>"},{"location":"v2/development/migrations/#understanding-prisma-migrate","title":"Understanding Prisma Migrate","text":"<p>Prisma Migrate: - Tracks schema changes as SQL migration files - Stores migration history in <code>_prisma_migrations</code> table - Ensures schema consistency across environments - Supports rollback via version control</p> <p>Migration Files: - Located in <code>api/prisma/migrations/</code> - Named with timestamp: <code>20260213123456_description/</code> - Contains <code>migration.sql</code> (SQL commands)</p> <p>Migration States: - Pending: Not yet applied - Applied: Successfully executed - Failed: Execution error (requires manual fix)</p>"},{"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":"<p>Edit <code>api/prisma/schema.prisma</code>:</p> <pre><code>// 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</code></pre>"},{"location":"v2/development/migrations/#step-2-validate-schema","title":"Step 2: Validate Schema","text":"<pre><code>cd api\nnpx prisma validate\n</code></pre> <p>Expected output: <pre><code>Environment variables loaded from .env\nPrisma schema loaded from prisma/schema.prisma\nThe schema is valid \u2714\n</code></pre></p> <p>If errors: <pre><code>Error validating model \"User\": Field \"foo\" references unknown model \"Bar\"\n</code></pre></p> <p>Fix errors before proceeding.</p>"},{"location":"v2/development/migrations/#step-3-create-migration","title":"Step 3: Create Migration","text":"<pre><code>cd api\nnpx prisma migrate dev --name add_user_name\n</code></pre> <p>What happens: 1. Prisma detects schema changes 2. Generates SQL migration file 3. Prompts for migration name (or uses <code>--name</code> argument) 4. Applies migration to development database 5. Regenerates Prisma Client</p> <p>Expected output: <pre><code>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</code></pre></p> <p>Migration file created: <pre><code>api/prisma/migrations/\n\u2514\u2500\u2500 20260213123456_add_user_name/\n \u2514\u2500\u2500 migration.sql\n</code></pre></p>"},{"location":"v2/development/migrations/#step-4-review-generated-sql","title":"Step 4: Review Generated SQL","text":"<pre><code>cd api\ncat prisma/migrations/20260213123456_add_user_name/migration.sql\n</code></pre> <p>Example SQL: <pre><code>-- AlterTable\nALTER TABLE \"users\" ADD COLUMN \"name\" TEXT;\n</code></pre></p> <p>Verify SQL is correct: - Check table names match expectations - Ensure data types are correct - Look for unexpected <code>DROP</code> commands</p>"},{"location":"v2/development/migrations/#step-5-test-migration","title":"Step 5: Test Migration","text":"<p>Migration already applied to development DB. Verify:</p> <pre><code># Check schema with Prisma Studio\ncd api\nnpx prisma studio\n</code></pre> <p>Or query directly:</p> <pre><code># 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</code></pre> <p>Expected output: <pre><code>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</code></pre></p>"},{"location":"v2/development/migrations/#step-6-commit-migration","title":"Step 6: Commit Migration","text":"<pre><code>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</code></pre> <p>Always commit: - Migration directory (<code>prisma/migrations/*/</code>) - Updated <code>schema.prisma</code></p>"},{"location":"v2/development/migrations/#applying-migrations-production","title":"Applying Migrations (Production)","text":""},{"location":"v2/development/migrations/#in-production-environment","title":"In Production Environment","text":"<pre><code>cd api\nnpx prisma migrate deploy\n</code></pre> <p>What it does: - Checks <code>_prisma_migrations</code> table for applied migrations - Applies only pending migrations - Does NOT create new migrations - Safe for production</p> <p>Expected output: <pre><code>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</code></pre></p>"},{"location":"v2/development/migrations/#in-docker","title":"In Docker","text":"<pre><code># 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</code></pre>"},{"location":"v2/development/migrations/#cicd-deployment","title":"CI/CD Deployment","text":"<pre><code># GitHub Actions example\n- name: Run migrations\n run: |\n cd api\n npx prisma migrate deploy\n</code></pre>"},{"location":"v2/development/migrations/#migration-best-practices","title":"Migration Best Practices","text":""},{"location":"v2/development/migrations/#1-incremental-changes","title":"1. Incremental Changes","text":"<p>Make small, focused migrations:</p> <p>Good: <pre><code># 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</code></pre></p> <p>Bad: <pre><code># One huge migration\nnpx prisma migrate dev --name update_user_model\n# (adds 10 fields, 3 relations, 5 indexes)\n</code></pre></p>"},{"location":"v2/development/migrations/#2-descriptive-names","title":"2. Descriptive Names","text":"<p>Use clear migration names:</p> <p>Good: <pre><code>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</code></pre></p> <p>Bad: <pre><code>npx prisma migrate dev --name update\nnpx prisma migrate dev --name fix\nnpx prisma migrate dev --name changes\n</code></pre></p>"},{"location":"v2/development/migrations/#3-review-sql-before-committing","title":"3. Review SQL Before Committing","text":"<p>Always review generated SQL:</p> <pre><code>cat prisma/migrations/*/migration.sql\n</code></pre> <p>Watch for: - Unexpected <code>DROP TABLE</code> or <code>DROP COLUMN</code> - Missing <code>NOT NULL</code> constraints - Incorrect data types - Missing indexes on foreign keys</p>"},{"location":"v2/development/migrations/#4-backup-before-migration-production","title":"4. Backup Before Migration (Production)","text":"<pre><code># 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</code></pre>"},{"location":"v2/development/migrations/#5-test-on-staging-first","title":"5. Test on Staging First","text":"<p>Never deploy migrations directly to production:</p> <pre><code>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</code></pre>"},{"location":"v2/development/migrations/#common-migration-scenarios","title":"Common Migration Scenarios","text":""},{"location":"v2/development/migrations/#add-new-field","title":"Add New Field","text":"<pre><code>// schema.prisma\nmodel User {\n id Int @id @default(autoincrement())\n email String @unique\n name String? // New nullable field\n}\n</code></pre> <pre><code>npx prisma migrate dev --name add_user_name\n</code></pre> <p>Generated SQL: <pre><code>ALTER TABLE \"users\" ADD COLUMN \"name\" TEXT;\n</code></pre></p>"},{"location":"v2/development/migrations/#add-required-field-with-default","title":"Add Required Field (with Default)","text":"<pre><code>model User {\n id Int @id @default(autoincrement())\n email String @unique\n createdAt DateTime @default(now()) // New required field with default\n}\n</code></pre> <pre><code>npx prisma migrate dev --name add_created_at\n</code></pre> <p>Generated SQL: <pre><code>ALTER TABLE \"users\" ADD COLUMN \"created_at\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;\n</code></pre></p>"},{"location":"v2/development/migrations/#add-new-table","title":"Add New Table","text":"<pre><code>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</code></pre> <pre><code>npx prisma migrate dev --name create_posts_table\n</code></pre> <p>Generated SQL: <pre><code>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</code></pre></p>"},{"location":"v2/development/migrations/#add-relation","title":"Add Relation","text":"<pre><code>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</code></pre> <pre><code>npx prisma migrate dev --name add_campaign_user_relation\n</code></pre> <p>Generated SQL: <pre><code>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</code></pre></p>"},{"location":"v2/development/migrations/#change-field-type","title":"Change Field Type","text":"<pre><code>// Before\nmodel User {\n age Int\n}\n\n// After\nmodel User {\n age String // Changed from Int to String\n}\n</code></pre> <pre><code>npx prisma migrate dev --name change_user_age_to_string\n</code></pre> <p>Generated SQL: <pre><code>ALTER TABLE \"users\" ALTER COLUMN \"age\" SET DATA TYPE TEXT;\n</code></pre></p> <p>Warning: This may fail if data cannot be cast. Consider data migration first.</p>"},{"location":"v2/development/migrations/#add-unique-constraint","title":"Add Unique Constraint","text":"<pre><code>model User {\n email String @unique // Add unique constraint\n}\n</code></pre> <pre><code>npx prisma migrate dev --name make_email_unique\n</code></pre> <p>Generated SQL: <pre><code>CREATE UNIQUE INDEX \"users_email_key\" ON \"users\"(\"email\");\n</code></pre></p>"},{"location":"v2/development/migrations/#add-index","title":"Add Index","text":"<pre><code>model User {\n email String\n\n @@index([email]) // Add index\n}\n</code></pre> <pre><code>npx prisma migrate dev --name add_email_index\n</code></pre> <p>Generated SQL: <pre><code>CREATE INDEX \"users_email_idx\" ON \"users\"(\"email\");\n</code></pre></p>"},{"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":"<pre><code>cd api\nnpx prisma migrate status\n</code></pre> <p>Expected output: <pre><code>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</code></pre></p>"},{"location":"v2/development/migrations/#view-migration-history","title":"View Migration History","text":"<pre><code># List migration files\nls -la api/prisma/migrations/\n\n# View specific migration\ncat api/prisma/migrations/20260213123456_add_user_name/migration.sql\n</code></pre>"},{"location":"v2/development/migrations/#check-database-migration-table","title":"Check Database Migration Table","text":"<pre><code>docker compose exec v2-postgres psql -U changemaker_v2 -d changemaker_v2_db -c \"SELECT * FROM _prisma_migrations;\"\n</code></pre> <p>Output: <pre><code>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</code></pre></p>"},{"location":"v2/development/migrations/#rollback-strategies","title":"Rollback Strategies","text":"<p>Prisma Migrate does NOT have automatic rollback. Use these strategies:</p>"},{"location":"v2/development/migrations/#1-version-control-rollback","title":"1. Version Control Rollback","text":"<pre><code># 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</code></pre>"},{"location":"v2/development/migrations/#2-manual-rollback-migration","title":"2. Manual Rollback Migration","text":"<p>Create a new migration to reverse changes:</p> <pre><code>// 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</code></pre> <pre><code>npx prisma migrate dev --name remove_user_name\n</code></pre> <p>Generated SQL: <pre><code>ALTER TABLE \"users\" DROP COLUMN \"name\";\n</code></pre></p>"},{"location":"v2/development/migrations/#3-database-restore-last-resort","title":"3. Database Restore (Last Resort)","text":"<pre><code># 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</code></pre>"},{"location":"v2/development/migrations/#4-reset-development-database","title":"4. Reset Development Database","text":"<p>WARNING: Deletes all data!</p> <pre><code>cd api\nnpx prisma migrate reset\n</code></pre> <p>This: 1. Drops all tables 2. Re-applies all migrations from scratch 3. Runs seed script</p>"},{"location":"v2/development/migrations/#handling-migration-conflicts","title":"Handling Migration Conflicts","text":""},{"location":"v2/development/migrations/#schema-drift","title":"Schema Drift","text":"<p>Problem: Database schema doesn't match Prisma schema.</p> <p>Symptoms: <pre><code>Error: Database schema is not in sync with the migration history\n</code></pre></p> <p>Solution:</p> <pre><code># 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</code></pre>"},{"location":"v2/development/migrations/#failed-migration","title":"Failed Migration","text":"<p>Problem: Migration fails during apply.</p> <p>Symptoms: <pre><code>Error: Migration failed with error:\n ALTER TABLE \"users\" ADD COLUMN \"age\" INTEGER NOT NULL;\n ERROR: column \"age\" contains null values\n</code></pre></p> <p>Solution:</p> <pre><code># 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</code></pre>"},{"location":"v2/development/migrations/#conflicting-migrations-team-environment","title":"Conflicting Migrations (Team Environment)","text":"<p>Problem: Two developers create migrations simultaneously.</p> <p>Solution:</p> <pre><code># 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</code></pre>"},{"location":"v2/development/migrations/#data-migrations","title":"Data Migrations","text":"<p>Prisma Migrate handles schema changes, not data changes. For data transformations:</p>"},{"location":"v2/development/migrations/#option-1-custom-sql-in-migration","title":"Option 1: Custom SQL in Migration","text":"<p>Edit generated migration file:</p> <pre><code>-- 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</code></pre>"},{"location":"v2/development/migrations/#option-2-separate-data-migration-script","title":"Option 2: Separate Data Migration Script","text":"<pre><code>// 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</code></pre> <p>Run after migration:</p> <pre><code>npx tsx prisma/data-migrations/20260213-populate-full-name.ts\n</code></pre>"},{"location":"v2/development/migrations/#drizzle-push-media-api","title":"Drizzle Push (Media API)","text":""},{"location":"v2/development/migrations/#drizzle-overview","title":"Drizzle Overview","text":"<p>Drizzle Kit Push: - Syncs schema directly to database - No migration files generated - Fast iteration for prototyping - Used only for Media API tables</p> <p>Schema Location: - <code>api/src/modules/media/db/schema.ts</code></p> <p>When to Use: - Rapid prototyping - Development only - Media API tables (videos, jobs, reactions)</p> <p>When NOT to Use: - Production deployments - Main API tables (use Prisma) - When migration history is needed</p>"},{"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":"<p>Edit <code>api/src/modules/media/db/schema.ts</code>:</p> <pre><code>// 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</code></pre>"},{"location":"v2/development/migrations/#step-2-push-schema","title":"Step 2: Push Schema","text":"<pre><code>cd api\nnpm run drizzle:push\n</code></pre> <p>Or directly:</p> <pre><code>cd api\nnpx drizzle-kit push\n</code></pre> <p>What happens: 1. Drizzle compares schema to database 2. Generates SQL for changes 3. Applies changes immediately 4. No migration files created</p> <p>Expected output: <pre><code>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</code></pre></p>"},{"location":"v2/development/migrations/#step-3-verify-changes","title":"Step 3: Verify Changes","text":"<pre><code># Check with Drizzle Studio\ncd api\nnpx drizzle-kit studio\n</code></pre> <p>Or query directly:</p> <pre><code>docker compose exec v2-postgres psql -U changemaker_v2 -d changemaker_v2_db -c \"\\d videos\"\n</code></pre>"},{"location":"v2/development/migrations/#drizzle-best-practices","title":"Drizzle Best Practices","text":""},{"location":"v2/development/migrations/#1-development-only","title":"1. Development Only","text":"<p>Use Drizzle Push only in development:</p> <p>Good: <pre><code># Development\nnpm run drizzle:push\n</code></pre></p> <p>Bad: <pre><code># Production (use Prisma migrate for production schema changes)\nnpm run drizzle:push\n</code></pre></p>"},{"location":"v2/development/migrations/#2-backup-before-push","title":"2. Backup Before Push","text":"<p>Always backup before pushing schema:</p> <pre><code># 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</code></pre>"},{"location":"v2/development/migrations/#3-test-changes-locally","title":"3. Test Changes Locally","text":"<p>Never push untested schema changes:</p> <pre><code># 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</code></pre>"},{"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":"<p>After migrations, seed database:</p> <pre><code>cd api\nnpx prisma db seed\n</code></pre> <p>What it does: - Runs <code>prisma/seed.ts</code> - Creates admin user - Creates default settings - Creates sample blocks</p> <p>Expected output: <pre><code>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</code></pre></p>"},{"location":"v2/development/migrations/#custom-seed-data","title":"Custom Seed Data","text":"<p>Edit <code>api/prisma/seed.ts</code>:</p> <pre><code>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</code></pre>"},{"location":"v2/development/migrations/#cicd-integration","title":"CI/CD Integration","text":""},{"location":"v2/development/migrations/#github-actions-example","title":"GitHub Actions Example","text":"<pre><code>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</code></pre>"},{"location":"v2/development/migrations/#docker-deployment","title":"Docker Deployment","text":"<pre><code># 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</code></pre>"},{"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":"<p>Problem: <pre><code>Error: column \"name\" of relation \"users\" already exists\n</code></pre></p> <p>Solution:</p> <pre><code># 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</code></pre>"},{"location":"v2/development/migrations/#migration-fails-with-relation-does-not-exist","title":"Migration Fails with \"Relation Does Not Exist\"","text":"<p>Problem: <pre><code>Error: relation \"posts\" does not exist\n</code></pre></p> <p>Solution:</p> <pre><code># 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</code></pre>"},{"location":"v2/development/migrations/#schema-out-of-sync","title":"Schema Out of Sync","text":"<p>Problem: <pre><code>Error: Database schema is not in sync\n</code></pre></p> <p>Solution:</p> <pre><code># 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</code></pre>"},{"location":"v2/development/migrations/#drizzle-push-fails","title":"Drizzle Push Fails","text":"<p>Problem: <pre><code>Error: Could not push schema\n</code></pre></p> <p>Solution:</p> <pre><code># 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</code></pre>"},{"location":"v2/development/migrations/#related-documentation","title":"Related Documentation","text":"<ul> <li>Setup: Local Development Setup</li> <li>Commands: NPM Commands Reference</li> <li>Docker: Docker Workflow</li> <li>Database: Database Schema</li> <li>Deployment: Production Deployment</li> </ul>"},{"location":"v2/development/migrations/#summary","title":"Summary","text":"<p>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</p> <p>Quick Reference: <pre><code># 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</code></pre></p>"},{"location":"v2/development/npm-commands/","title":"NPM Commands Reference","text":"<p>Complete reference for all npm scripts in Changemaker Lite V2.</p>"},{"location":"v2/development/npm-commands/#overview","title":"Overview","text":"<p>Changemaker Lite V2 uses npm scripts for development, building, testing, and database management. Scripts are defined in <code>package.json</code> files in two main directories:</p> <ul> <li>api/package.json - Backend API scripts (Express + Fastify)</li> <li>admin/package.json - Frontend GUI scripts (React + Vite)</li> </ul> <p>This guide documents all available scripts, their usage, and common combinations.</p>"},{"location":"v2/development/npm-commands/#api-scripts","title":"API Scripts","text":"<p>Location: <code>api/package.json</code></p>"},{"location":"v2/development/npm-commands/#development-scripts","title":"Development Scripts","text":""},{"location":"v2/development/npm-commands/#npm-run-dev","title":"<code>npm run dev</code>","text":"<p>Starts the Express API server in development mode with hot reload.</p> <pre><code>cd api\nnpm run dev\n</code></pre> <p>What it does: - Runs <code>tsx watch src/server.ts</code> - Auto-restarts on file changes (<code>.ts</code> files) - Loads environment from <code>.env</code> - Runs on port <code>API_PORT</code> (default: 4000)</p> <p>Output: <pre><code>Server running on port 4000\nDatabase connected\nRedis connected\nBullMQ worker started\n</code></pre></p> <p>Use when: - Developing API endpoints - Testing backend changes - Debugging server code</p>"},{"location":"v2/development/npm-commands/#npm-run-devmedia","title":"<code>npm run dev:media</code>","text":"<p>Starts the Fastify Media API server in development mode.</p> <pre><code>cd api\nnpm run dev:media\n</code></pre> <p>What it does: - Runs <code>tsx watch src/media-server.ts</code> - Auto-restarts on file changes - Runs on port <code>MEDIA_API_PORT</code> (default: 4100)</p> <p>Output: <pre><code>Media API server running on port 4100\nDatabase connected\n</code></pre></p> <p>Use when: - Developing media features (video upload, reactions) - Testing Media API endpoints - Working on FFprobe integration</p>"},{"location":"v2/development/npm-commands/#build-scripts","title":"Build Scripts","text":""},{"location":"v2/development/npm-commands/#npm-run-build","title":"<code>npm run build</code>","text":"<p>Compiles TypeScript to JavaScript for production.</p> <pre><code>cd api\nnpm run build\n</code></pre> <p>What it does: - Runs <code>tsc --build</code> - Outputs to <code>dist/</code> directory - Type-checks all code - Fails on type errors</p> <p>Output: <pre><code>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</code></pre></p> <p>Use when: - Preparing for production deployment - Verifying build succeeds - Creating Docker images</p>"},{"location":"v2/development/npm-commands/#npm-run-clean","title":"<code>npm run clean</code>","text":"<p>Removes compiled JavaScript and build artifacts.</p> <pre><code>cd api\nnpm run clean\n</code></pre> <p>What it does: - Deletes <code>dist/</code> directory - Removes <code>*.tsbuildinfo</code> files</p> <p>Use when: - Starting fresh build - Fixing build cache issues - Cleaning up after development</p>"},{"location":"v2/development/npm-commands/#production-scripts","title":"Production Scripts","text":""},{"location":"v2/development/npm-commands/#npm-start","title":"<code>npm start</code>","text":"<p>Runs the compiled API server (production mode).</p> <pre><code>cd api\nnpm start\n</code></pre> <p>What it does: - Runs <code>node dist/server.js</code> - Requires <code>npm run build</code> first - Uses production environment (<code>NODE_ENV=production</code>)</p> <p>Output: <pre><code>Server running on port 4000\nDatabase connected\nRedis connected\n</code></pre></p> <p>Use when: - Running in production (Docker) - Testing production build locally</p>"},{"location":"v2/development/npm-commands/#npm-run-startmedia","title":"<code>npm run start:media</code>","text":"<p>Runs the compiled Media API server (production mode).</p> <pre><code>cd api\nnpm run start:media\n</code></pre> <p>What it does: - Runs <code>node dist/media-server.js</code> - Requires <code>npm run build</code> first</p> <p>Use when: - Running Media API in production - Testing production Media API</p>"},{"location":"v2/development/npm-commands/#code-quality-scripts","title":"Code Quality Scripts","text":""},{"location":"v2/development/npm-commands/#npm-run-type-check","title":"<code>npm run type-check</code>","text":"<p>Type-checks TypeScript without emitting files.</p> <pre><code>cd api\nnpm run type-check\n</code></pre> <p>What it does: - Runs <code>tsc --noEmit</code> - Reports type errors - Does NOT generate files</p> <p>Output: <pre><code># 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</code></pre></p> <p>Use when: - Before committing code - In CI/CD pipeline - Debugging type errors</p>"},{"location":"v2/development/npm-commands/#npm-run-lint","title":"<code>npm run lint</code>","text":"<p>Runs ESLint to check code style.</p> <pre><code>cd api\nnpm run lint\n</code></pre> <p>What it does: - Runs <code>eslint src/ --ext .ts</code> - Reports style violations - Checks for common errors</p> <p>Output: <pre><code># 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</code></pre></p> <p>Use when: - Before committing code - Enforcing code style - Finding potential bugs</p>"},{"location":"v2/development/npm-commands/#npm-run-lintfix","title":"<code>npm run lint:fix</code>","text":"<p>Automatically fixes ESLint errors where possible.</p> <pre><code>cd api\nnpm run lint:fix\n</code></pre> <p>What it does: - Runs <code>eslint src/ --ext .ts --fix</code> - Auto-fixes style issues (formatting, imports, etc.) - Reports unfixable errors</p> <p>Use when: - After writing new code - Cleaning up formatting - Before commit</p>"},{"location":"v2/development/npm-commands/#npm-run-format","title":"<code>npm run format</code>","text":"<p>Formats code with Prettier.</p> <pre><code>cd api\nnpm run format\n</code></pre> <p>What it does: - Runs <code>prettier --write \"src/**/*.{ts,js,json}\"</code> - Formats all TypeScript, JavaScript, and JSON files - Overwrites files in place</p> <p>Use when: - Standardizing code format - After merge conflicts - Team-wide formatting</p>"},{"location":"v2/development/npm-commands/#npm-run-formatcheck","title":"<code>npm run format:check</code>","text":"<p>Checks if code is formatted correctly (CI).</p> <pre><code>cd api\nnpm run format:check\n</code></pre> <p>What it does: - Runs <code>prettier --check \"src/**/*.{ts,js,json}\"</code> - Reports unformatted files - Does NOT modify files</p> <p>Use when: - In CI/CD pipeline - Verifying format before commit</p>"},{"location":"v2/development/npm-commands/#database-scripts","title":"Database Scripts","text":""},{"location":"v2/development/npm-commands/#npm-run-prismamigrate","title":"<code>npm run prisma:migrate</code>","text":"<p>Creates and applies a new Prisma migration.</p> <pre><code>cd api\nnpm run prisma:migrate\n# Or with name:\nnpx prisma migrate dev --name add_user_field\n</code></pre> <p>What it does: - Prompts for migration name - Generates SQL migration in <code>prisma/migrations/</code> - Applies migration to development database - Regenerates Prisma Client</p> <p>Output: <pre><code>\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</code></pre></p> <p>Use when: - Changing database schema - Adding new models - Modifying fields</p>"},{"location":"v2/development/npm-commands/#npm-run-prismadeploy","title":"<code>npm run prisma:deploy</code>","text":"<p>Applies pending migrations (production).</p> <pre><code>cd api\nnpm run prisma:deploy\n</code></pre> <p>What it does: - Runs <code>prisma migrate deploy</code> - Applies unapplied migrations only - Does NOT create new migrations - Safe for production</p> <p>Output: <pre><code>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</code></pre></p> <p>Use when: - Deploying to production - Applying migrations in Docker - CI/CD deployment</p>"},{"location":"v2/development/npm-commands/#npm-run-prismaseed","title":"<code>npm run prisma:seed</code>","text":"<p>Seeds database with initial data.</p> <pre><code>cd api\nnpm run prisma:seed\n</code></pre> <p>What it does: - Runs <code>tsx prisma/seed.ts</code> - Creates admin user - Creates default settings - Creates sample blocks</p> <p>Output: <pre><code>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</code></pre></p> <p>Use when: - First-time setup - After reset - Populating test data</p>"},{"location":"v2/development/npm-commands/#npm-run-prismastudio","title":"<code>npm run prisma:studio</code>","text":"<p>Opens Prisma Studio (database GUI).</p> <pre><code>cd api\nnpm run prisma:studio\n</code></pre> <p>What it does: - Runs <code>prisma studio</code> - Opens browser at http://localhost:5555 - Shows all tables and data - Allows CRUD operations</p> <p>Use when: - Inspecting database - Manual data editing - Debugging data issues</p>"},{"location":"v2/development/npm-commands/#npm-run-prismareset","title":"<code>npm run prisma:reset</code>","text":"<p>Resets database (DESTRUCTIVE).</p> <pre><code>cd api\nnpm run prisma:reset\n</code></pre> <p>What it does: - Drops all tables - Re-applies all migrations - Runs seed script - DELETES ALL DATA</p> <p>Output: <pre><code>\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</code></pre></p> <p>Use when: - Starting fresh in development - Fixing migration conflicts - NEVER in production</p>"},{"location":"v2/development/npm-commands/#npm-run-prismavalidate","title":"<code>npm run prisma:validate</code>","text":"<p>Validates Prisma schema.</p> <pre><code>cd api\nnpm run prisma:validate\n</code></pre> <p>What it does: - Runs <code>prisma validate</code> - Checks schema syntax - Verifies relations - Does NOT touch database</p> <p>Output: <pre><code># Success\nThe schema is valid \u2714\n\n# Errors\nError validating model \"User\": Field \"foo\" references unknown model \"Bar\"\n</code></pre></p> <p>Use when: - After editing schema - Before creating migration - In CI/CD pipeline</p>"},{"location":"v2/development/npm-commands/#npm-run-drizzlepush","title":"<code>npm run drizzle:push</code>","text":"<p>Pushes Drizzle schema changes to database (Media API).</p> <pre><code>cd api\nnpm run drizzle:push\n</code></pre> <p>What it does: - Runs <code>drizzle-kit push</code> - Syncs <code>src/modules/media/db/schema.ts</code> to database - Does NOT create migration files - Direct schema sync</p> <p>Output: <pre><code>Reading config from drizzle.config.ts\nPushing schema to database...\n\u2714 Schema pushed successfully\n</code></pre></p> <p>Use when: - Changing Media API tables (videos, jobs, reactions) - Rapid prototyping (no migrations) - Development only</p>"},{"location":"v2/development/npm-commands/#npm-run-drizzlestudio","title":"<code>npm run drizzle:studio</code>","text":"<p>Opens Drizzle Studio (database GUI for Media API).</p> <pre><code>cd api\nnpm run drizzle:studio\n</code></pre> <p>What it does: - Runs <code>drizzle-kit studio</code> - Opens browser at http://localhost:4983 - Shows Media API tables only</p> <p>Use when: - Inspecting media tables - Debugging video data - Manual media data editing</p>"},{"location":"v2/development/npm-commands/#testing-scripts","title":"Testing Scripts","text":""},{"location":"v2/development/npm-commands/#npm-test","title":"<code>npm test</code>","text":"<p>Runs all tests (when configured).</p> <pre><code>cd api\nnpm test\n</code></pre> <p>What it does: - Runs Jest test suite - Executes <code>*.test.ts</code> files - Reports pass/fail</p> <p>Note: Tests are part of Phase 15 (in progress).</p>"},{"location":"v2/development/npm-commands/#npm-run-testwatch","title":"<code>npm run test:watch</code>","text":"<p>Runs tests in watch mode.</p> <pre><code>cd api\nnpm run test:watch\n</code></pre> <p>What it does: - Runs <code>jest --watch</code> - Re-runs tests on file changes</p>"},{"location":"v2/development/npm-commands/#npm-run-testcoverage","title":"<code>npm run test:coverage</code>","text":"<p>Runs tests with coverage report.</p> <pre><code>cd api\nnpm run test:coverage\n</code></pre> <p>What it does: - Runs <code>jest --coverage</code> - Generates coverage report in <code>coverage/</code></p>"},{"location":"v2/development/npm-commands/#utility-scripts","title":"Utility Scripts","text":""},{"location":"v2/development/npm-commands/#npm-run-envvalidate","title":"<code>npm run env:validate</code>","text":"<p>Validates required environment variables.</p> <pre><code>cd api\nnpm run env:validate\n</code></pre> <p>What it does: - Checks <code>.env</code> has required vars - Uses Zod validation (from <code>config/env.ts</code>) - Fails if vars missing/invalid</p> <p>Output: <pre><code># Success\n\u2714 Environment variables valid\n\n# Errors\nError: Missing required environment variables:\n - JWT_ACCESS_SECRET\n - REDIS_PASSWORD\n</code></pre></p> <p>Use when: - After editing .env - Before deployment - Debugging config issues</p>"},{"location":"v2/development/npm-commands/#admin-scripts","title":"Admin Scripts","text":"<p>Location: <code>admin/package.json</code></p>"},{"location":"v2/development/npm-commands/#development-scripts_1","title":"Development Scripts","text":""},{"location":"v2/development/npm-commands/#npm-run-dev_1","title":"<code>npm run dev</code>","text":"<p>Starts Vite development server with HMR.</p> <pre><code>cd admin\nnpm run dev\n</code></pre> <p>What it does: - Runs <code>vite</code> - Starts dev server on port <code>ADMIN_PORT</code> (default: 3000) - Enables Hot Module Replacement (HMR) - Proxies API requests to <code>VITE_API_URL</code></p> <p>Output: <pre><code> VITE v5.x.x ready in 500 ms\n\n \u279c Local: http://localhost:3000/\n \u279c Network: use --host to expose\n</code></pre></p> <p>Use when: - Developing frontend components - Testing UI changes - Working on React code</p>"},{"location":"v2/development/npm-commands/#build-scripts_1","title":"Build Scripts","text":""},{"location":"v2/development/npm-commands/#npm-run-build_1","title":"<code>npm run build</code>","text":"<p>Builds production-optimized bundle.</p> <pre><code>cd admin\nnpm run build\n</code></pre> <p>What it does: - Runs <code>tsc --noEmit && vite build</code> - Type-checks TypeScript - Bundles JavaScript/CSS - Optimizes assets (minify, tree-shake) - Outputs to <code>dist/</code></p> <p>Output: <pre><code>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</code></pre></p> <p>Use when: - Preparing for production deployment - Creating Docker image - Verifying build size</p>"},{"location":"v2/development/npm-commands/#npm-run-preview","title":"<code>npm run preview</code>","text":"<p>Previews production build locally.</p> <pre><code>cd admin\nnpm run preview\n</code></pre> <p>What it does: - Runs <code>vite preview</code> - Serves <code>dist/</code> directory - Runs on port 4173 (Vite default)</p> <p>Output: <pre><code> \u279c Local: http://localhost:4173/\n \u279c Network: use --host to expose\n</code></pre></p> <p>Use when: - Testing production build - Verifying optimizations - Before deployment</p>"},{"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":"<code>npm run type-check</code>","text":"<p>Type-checks TypeScript without emitting files.</p> <pre><code>cd admin\nnpm run type-check\n</code></pre> <p>What it does: - Runs <code>tsc --noEmit</code> - Reports type errors - Checks all <code>.ts</code> and <code>.tsx</code> files</p> <p>Output: <pre><code># Success (no output)\n\n# Errors\nsrc/pages/UsersPage.tsx:123:45 - error TS2339: Property 'foo' does not exist on type 'User'.\n</code></pre></p> <p>Use when: - Before committing code - In CI/CD pipeline - Debugging type errors</p>"},{"location":"v2/development/npm-commands/#npm-run-lint_1","title":"<code>npm run lint</code>","text":"<p>Runs ESLint to check code style.</p> <pre><code>cd admin\nnpm run lint\n</code></pre> <p>What it does: - Runs <code>eslint src/ --ext .ts,.tsx</code> - Reports style violations - Checks React best practices</p> <p>Output: <pre><code># 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</code></pre></p> <p>Use when: - Before committing code - Enforcing code style - Finding potential bugs</p>"},{"location":"v2/development/npm-commands/#npm-run-lintfix_1","title":"<code>npm run lint:fix</code>","text":"<p>Automatically fixes ESLint errors where possible.</p> <pre><code>cd admin\nnpm run lint:fix\n</code></pre> <p>What it does: - Runs <code>eslint src/ --ext .ts,.tsx --fix</code> - Auto-fixes style issues - Reports unfixable errors</p> <p>Use when: - After writing new code - Cleaning up formatting - Before commit</p>"},{"location":"v2/development/npm-commands/#npm-run-format_1","title":"<code>npm run format</code>","text":"<p>Formats code with Prettier.</p> <pre><code>cd admin\nnpm run format\n</code></pre> <p>What it does: - Runs <code>prettier --write \"src/**/*.{ts,tsx,css,json}\"</code> - Formats all source files - Overwrites files in place</p> <p>Use when: - Standardizing code format - After merge conflicts - Team-wide formatting</p>"},{"location":"v2/development/npm-commands/#npm-run-formatcheck_1","title":"<code>npm run format:check</code>","text":"<p>Checks if code is formatted correctly (CI).</p> <pre><code>cd admin\nnpm run format:check\n</code></pre> <p>What it does: - Runs <code>prettier --check \"src/**/*.{ts,tsx,css,json}\"</code> - Reports unformatted files - Does NOT modify files</p> <p>Use when: - In CI/CD pipeline - Verifying format before commit</p>"},{"location":"v2/development/npm-commands/#testing-scripts_1","title":"Testing Scripts","text":""},{"location":"v2/development/npm-commands/#npm-test_1","title":"<code>npm test</code>","text":"<p>Runs all tests (when configured).</p> <pre><code>cd admin\nnpm test\n</code></pre> <p>What it does: - Runs Vitest test suite - Executes <code>*.test.tsx</code> and <code>*.spec.tsx</code> files - Reports pass/fail</p> <p>Note: Tests are part of Phase 15 (in progress).</p>"},{"location":"v2/development/npm-commands/#npm-run-testwatch_1","title":"<code>npm run test:watch</code>","text":"<p>Runs tests in watch mode.</p> <pre><code>cd admin\nnpm run test:watch\n</code></pre> <p>What it does: - Runs <code>vitest</code> - Re-runs tests on file changes</p>"},{"location":"v2/development/npm-commands/#npm-run-testui","title":"<code>npm run test:ui</code>","text":"<p>Runs tests with UI (Vitest UI).</p> <pre><code>cd admin\nnpm run test:ui\n</code></pre> <p>What it does: - Runs <code>vitest --ui</code> - Opens browser with test UI - Shows test results visually</p>"},{"location":"v2/development/npm-commands/#npm-run-testcoverage_1","title":"<code>npm run test:coverage</code>","text":"<p>Runs tests with coverage report.</p> <pre><code>cd admin\nnpm run test:coverage\n</code></pre> <p>What it does: - Runs <code>vitest --coverage</code> - Generates coverage report in <code>coverage/</code></p>"},{"location":"v2/development/npm-commands/#utility-scripts_1","title":"Utility Scripts","text":""},{"location":"v2/development/npm-commands/#npm-run-clean_1","title":"<code>npm run clean</code>","text":"<p>Removes build artifacts and cache.</p> <pre><code>cd admin\nnpm run clean\n</code></pre> <p>What it does: - Deletes <code>dist/</code> directory - Removes <code>node_modules/.vite/</code> cache - Removes <code>tsconfig.tsbuildinfo</code></p> <p>Use when: - Starting fresh build - Fixing build cache issues - Cleaning up after development</p>"},{"location":"v2/development/npm-commands/#docker-commands","title":"Docker Commands","text":"<p>When running services in Docker, use <code>docker compose exec</code> to run npm scripts:</p>"},{"location":"v2/development/npm-commands/#api-in-docker","title":"API in Docker","text":"<pre><code># 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</code></pre>"},{"location":"v2/development/npm-commands/#admin-in-docker","title":"Admin in Docker","text":"<pre><code># 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</code></pre>"},{"location":"v2/development/npm-commands/#rebuild-containers","title":"Rebuild Containers","text":"<pre><code># Rebuild after package.json changes\ndocker compose build --no-cache api admin\n\n# Restart services\ndocker compose restart api admin\n</code></pre>"},{"location":"v2/development/npm-commands/#script-chaining","title":"Script Chaining","text":""},{"location":"v2/development/npm-commands/#sequential-execution","title":"Sequential Execution (&&)","text":"<p>Run scripts in sequence, stop on first failure:</p> <pre><code># 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</code></pre>"},{"location":"v2/development/npm-commands/#parallel-execution-npm-run-all","title":"Parallel Execution (npm-run-all)","text":"<p>Install <code>npm-run-all</code> for parallel script execution:</p> <pre><code># 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</code></pre>"},{"location":"v2/development/npm-commands/#prepost-hooks","title":"Pre/Post Hooks","text":"<p>npm automatically runs <code>pre*</code> and <code>post*</code> scripts:</p> <pre><code># 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</code></pre>"},{"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":"<pre><code># 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</code></pre>"},{"location":"v2/development/npm-commands/#pre-commit-quality-check","title":"Pre-Commit Quality Check","text":"<pre><code># 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</code></pre>"},{"location":"v2/development/npm-commands/#production-build","title":"Production Build","text":"<pre><code># 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</code></pre>"},{"location":"v2/development/npm-commands/#database-migration-workflow","title":"Database Migration Workflow","text":"<pre><code># 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</code></pre>"},{"location":"v2/development/npm-commands/#database-inspection","title":"Database Inspection","text":"<pre><code># 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</code></pre>"},{"location":"v2/development/npm-commands/#full-type-check","title":"Full Type Check","text":"<pre><code># 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</code></pre>"},{"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":"<pre><code>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</code></pre>"},{"location":"v2/development/npm-commands/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/development/npm-commands/#script-not-found","title":"Script Not Found","text":"<p>Problem: <pre><code>npm ERR! missing script: dev\n</code></pre></p> <p>Solution: - Check <code>package.json</code> has the script defined - Verify you're in correct directory (<code>api/</code> or <code>admin/</code>) - Run <code>npm install</code> to ensure dependencies installed</p>"},{"location":"v2/development/npm-commands/#permission-errors","title":"Permission Errors","text":"<p>Problem: <pre><code>Error: EACCES: permission denied\n</code></pre></p> <p>Solution: - Don't use <code>sudo npm</code> (creates permission issues) - Fix npm permissions: <code>sudo chown -R $(whoami) ~/.npm</code> - Or use nvm for user-level Node.js installation</p>"},{"location":"v2/development/npm-commands/#port-already-in-use","title":"Port Already in Use","text":"<p>Problem: <pre><code>Error: listen EADDRINUSE: address already in use :::4000\n</code></pre></p> <p>Solution: - Find and kill process using port: <code>lsof -ti:4000 | xargs kill -9</code> - Or change port in <code>.env</code>: <code>API_PORT=4002</code> - Or use Docker (isolated ports)</p>"},{"location":"v2/development/npm-commands/#typescript-errors-on-build","title":"TypeScript Errors on Build","text":"<p>Problem: <pre><code>src/modules/auth/auth.service.ts:45:12 - error TS2339\n</code></pre></p> <p>Solution: - Fix type errors in code - Or check <code>tsconfig.json</code> is correct - Or update type definitions: <code>npm install --save-dev @types/node@latest</code></p>"},{"location":"v2/development/npm-commands/#prisma-migration-conflicts","title":"Prisma Migration Conflicts","text":"<p>Problem: <pre><code>Error: P3005 The database schema is not in sync with the migration history\n</code></pre></p> <p>Solution: - Development: <code>npx prisma migrate reset</code> (DELETES DATA) - Production: <code>npx prisma migrate resolve --applied <migration_name></code> - Or create new migration to fix state</p>"},{"location":"v2/development/npm-commands/#npm-install-failures","title":"npm install Failures","text":"<p>Problem: <pre><code>npm ERR! code ERESOLVE\nnpm ERR! ERESOLVE unable to resolve dependency tree\n</code></pre></p> <p>Solution: - Clear cache: <code>npm cache clean --force</code> - Delete and reinstall: <code>rm -rf node_modules package-lock.json && npm install</code> - Use <code>--legacy-peer-deps</code> flag: <code>npm install --legacy-peer-deps</code></p>"},{"location":"v2/development/npm-commands/#vite-build-errors","title":"Vite Build Errors","text":"<p>Problem: <pre><code>Error: Could not resolve entry module (index.html)\n</code></pre></p> <p>Solution: - Ensure <code>index.html</code> exists in <code>admin/</code> - Check <code>vite.config.ts</code> has correct root - Clear cache: <code>rm -rf node_modules/.vite && npm run dev</code></p>"},{"location":"v2/development/npm-commands/#best-practices","title":"Best Practices","text":""},{"location":"v2/development/npm-commands/#script-naming-conventions","title":"Script Naming Conventions","text":"<ul> <li>dev - Development mode with hot reload</li> <li>build - Production build</li> <li>start - Run production build</li> <li>test - Run tests</li> <li>lint - Check code style</li> <li>lint:fix - Auto-fix code style</li> <li>format - Format code</li> <li>type-check - TypeScript validation</li> <li>clean - Remove build artifacts</li> </ul>"},{"location":"v2/development/npm-commands/#script-organization","title":"Script Organization","text":"<p>Group related scripts:</p> <pre><code>{\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</code></pre>"},{"location":"v2/development/npm-commands/#environment-specific-scripts","title":"Environment-Specific Scripts","text":"<p>Use cross-env for environment variables:</p> <pre><code>npm install --save-dev cross-env\n</code></pre> <pre><code>{\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</code></pre>"},{"location":"v2/development/npm-commands/#script-documentation","title":"Script Documentation","text":"<p>Add comments in package.json:</p> <pre><code>{\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</code></pre>"},{"location":"v2/development/npm-commands/#quick-reference","title":"Quick Reference","text":""},{"location":"v2/development/npm-commands/#api-scripts_1","title":"API Scripts","text":"<pre><code>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</code></pre>"},{"location":"v2/development/npm-commands/#admin-scripts_1","title":"Admin Scripts","text":"<pre><code>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</code></pre>"},{"location":"v2/development/npm-commands/#docker-scripts","title":"Docker Scripts","text":"<pre><code>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</code></pre>"},{"location":"v2/development/npm-commands/#related-documentation","title":"Related Documentation","text":"<ul> <li>Setup: Local Development Setup</li> <li>Workflow: Docker Workflow</li> <li>Database: Migrations Guide</li> <li>Testing: Testing Guide</li> <li>Code Style: Code Style Guide</li> <li>Debugging: Debugging Guide</li> </ul>"},{"location":"v2/development/npm-commands/#summary","title":"Summary","text":"<p>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</p> <p>Quick Start: <pre><code># 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</code></pre></p>"},{"location":"v2/development/testing/","title":"Testing Strategy and Guide","text":"<p>Comprehensive guide to testing Changemaker Lite V2, covering unit tests, integration tests, and end-to-end testing strategies.</p>"},{"location":"v2/development/testing/#overview","title":"Overview","text":"<p>Current Status: Phase 15 (Testing + Polish) in progress. Test infrastructure is being implemented.</p> <p>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</p>"},{"location":"v2/development/testing/#testing-philosophy","title":"Testing Philosophy","text":""},{"location":"v2/development/testing/#test-pyramid","title":"Test Pyramid","text":"<pre><code> /\\\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</code></pre> <p>Unit Tests (70%): - Test individual functions/components - Fast execution (milliseconds) - No external dependencies - Easy to write and maintain</p> <p>Integration Tests (20%): - Test multiple units working together - Test API routes with database - Test user flows in frontend - Moderate execution time</p> <p>End-to-End Tests (10%): - Test complete user journeys - Test across API and frontend - Slow execution (seconds) - Complex setup</p>"},{"location":"v2/development/testing/#testing-principles","title":"Testing Principles","text":"<ol> <li>Test Behavior, Not Implementation</li> <li>Test what the code does, not how it does it</li> <li> <p>Allows refactoring without breaking tests</p> </li> <li> <p>Arrange-Act-Assert (AAA) Pattern</p> </li> <li>Arrange: Set up test data and mocks</li> <li>Act: Execute the code under test</li> <li> <p>Assert: Verify expected behavior</p> </li> <li> <p>Independent Tests</p> </li> <li>Each test runs in isolation</li> <li>No shared state between tests</li> <li> <p>Tests can run in any order</p> </li> <li> <p>Fast Feedback</p> </li> <li>Tests run quickly (< 1 second each)</li> <li>Run tests in watch mode during development</li> <li> <p>Run full suite in CI/CD</p> </li> <li> <p>Readable Tests</p> </li> <li>Clear test names describing what is tested</li> <li>Simple setup and assertions</li> <li>Good error messages when tests fail</li> </ol>"},{"location":"v2/development/testing/#test-frameworks","title":"Test Frameworks","text":""},{"location":"v2/development/testing/#api-testing-jest","title":"API Testing (Jest)","text":"<p>Framework: Jest Location: <code>api/src/**/*.test.ts</code> Config: <code>api/jest.config.js</code></p> <p>Installation: <pre><code>cd api\nnpm install --save-dev jest @types/jest ts-jest\nnpm install --save-dev @types/supertest supertest\n</code></pre></p> <p>Configuration (jest.config.js): <pre><code>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</code></pre></p>"},{"location":"v2/development/testing/#frontend-testing-vitest-react-testing-library","title":"Frontend Testing (Vitest + React Testing Library)","text":"<p>Framework: Vitest (Vite-native test runner) Component Testing: React Testing Library Location: <code>admin/src/**/*.test.tsx</code>, <code>admin/src/**/*.spec.tsx</code> Config: <code>admin/vitest.config.ts</code></p> <p>Installation: <pre><code>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</code></pre></p> <p>Configuration (vitest.config.ts): <pre><code>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</code></pre></p> <p>Setup File (admin/src/test/setup.ts): <pre><code>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</code></pre></p>"},{"location":"v2/development/testing/#api-testing","title":"API Testing","text":""},{"location":"v2/development/testing/#unit-tests-service-layer","title":"Unit Tests (Service Layer)","text":"<p>Test business logic in service files:</p> <p>Example: api/src/modules/auth/auth.service.test.ts</p> <pre><code>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</code></pre>"},{"location":"v2/development/testing/#integration-tests-routes","title":"Integration Tests (Routes)","text":"<p>Test API endpoints with database:</p> <p>Example: api/src/modules/auth/auth.routes.test.ts</p> <pre><code>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</code></pre>"},{"location":"v2/development/testing/#database-testing","title":"Database Testing","text":"<p>Use separate test database:</p> <p>Environment Variable (.env.test): <pre><code>DATABASE_URL=postgresql://changemaker_v2:password@localhost:5433/changemaker_v2_test_db\n</code></pre></p> <p>Setup Script (api/src/test/setup.ts): <pre><code>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</code></pre></p>"},{"location":"v2/development/testing/#frontend-testing","title":"Frontend Testing","text":""},{"location":"v2/development/testing/#component-unit-tests","title":"Component Unit Tests","text":"<p>Test individual React components:</p> <p>Example: admin/src/components/UserCard.test.tsx</p> <pre><code>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</code></pre>"},{"location":"v2/development/testing/#component-integration-tests","title":"Component Integration Tests","text":"<p>Test user interactions:</p> <p>Example: admin/src/pages/LoginPage.test.tsx</p> <pre><code>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</code></pre>"},{"location":"v2/development/testing/#testing-hooks","title":"Testing Hooks","text":"<p>Test custom React hooks:</p> <p>Example: admin/src/hooks/useDebounce.test.ts</p> <pre><code>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</code></pre>"},{"location":"v2/development/testing/#testing-zustand-stores","title":"Testing Zustand Stores","text":"<p>Test state management:</p> <p>Example: admin/src/stores/auth.store.test.ts</p> <pre><code>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</code></pre>"},{"location":"v2/development/testing/#running-tests","title":"Running Tests","text":""},{"location":"v2/development/testing/#run-all-tests","title":"Run All Tests","text":"<pre><code># API tests\ncd api\nnpm test\n\n# Frontend tests\ncd admin\nnpm test\n</code></pre>"},{"location":"v2/development/testing/#watch-mode","title":"Watch Mode","text":"<p>Run tests automatically on file changes:</p> <pre><code># API tests (Jest watch)\ncd api\nnpm run test:watch\n\n# Frontend tests (Vitest watch)\ncd admin\nnpm run test:watch\n</code></pre>"},{"location":"v2/development/testing/#run-specific-tests","title":"Run Specific Tests","text":"<pre><code># 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</code></pre>"},{"location":"v2/development/testing/#coverage-reports","title":"Coverage Reports","text":"<p>Generate test coverage:</p> <pre><code># API coverage\ncd api\nnpm run test:coverage\n\n# Frontend coverage\ncd admin\nnpm run test:coverage\n</code></pre> <p>Coverage output: <pre><code>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</code></pre></p> <p>HTML report: - Located in <code>coverage/</code> directory - Open <code>coverage/index.html</code> in browser - Shows line-by-line coverage</p>"},{"location":"v2/development/testing/#cicd-testing","title":"CI/CD Testing","text":"<p>GitHub Actions Example:</p> <pre><code>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</code></pre>"},{"location":"v2/development/testing/#mocking","title":"Mocking","text":""},{"location":"v2/development/testing/#mocking-api-calls-frontend","title":"Mocking API Calls (Frontend)","text":"<pre><code>// 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</code></pre>"},{"location":"v2/development/testing/#mocking-database-backend","title":"Mocking Database (Backend)","text":"<pre><code>// 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</code></pre>"},{"location":"v2/development/testing/#mocking-external-services","title":"Mocking External Services","text":"<pre><code>// Mock email service\nvi.mock('../../services/email.service', () => ({\n EmailService: {\n sendEmail: vi.fn().mockResolvedValue(true)\n }\n}));\n</code></pre>"},{"location":"v2/development/testing/#mocking-environment-variables","title":"Mocking Environment Variables","text":"<pre><code>// 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</code></pre>"},{"location":"v2/development/testing/#best-practices","title":"Best Practices","text":""},{"location":"v2/development/testing/#test-naming","title":"Test Naming","text":"<p>Use descriptive test names:</p> <p>Good: <pre><code>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</code></pre></p> <p>Bad: <pre><code>it('works', async () => {});\nit('test login', async () => {});\nit('should work correctly', () => {});\n</code></pre></p>"},{"location":"v2/development/testing/#test-organization","title":"Test Organization","text":"<p>Group related tests:</p> <pre><code>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</code></pre>"},{"location":"v2/development/testing/#setup-and-teardown","title":"Setup and Teardown","text":"<p>Use beforeEach/afterEach for common setup:</p> <pre><code>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</code></pre>"},{"location":"v2/development/testing/#avoid-test-interdependence","title":"Avoid Test Interdependence","text":"<p>Each test should be independent:</p> <p>Good: <pre><code>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</code></pre></p> <p>Bad: <pre><code>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</code></pre></p>"},{"location":"v2/development/testing/#test-edge-cases","title":"Test Edge Cases","text":"<p>Test boundary conditions:</p> <pre><code>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</code></pre>"},{"location":"v2/development/testing/#async-testing","title":"Async Testing","text":"<p>Always use async/await for async tests:</p> <p>Good: <pre><code>it('should fetch users', async () => {\n const users = await userService.getUsers();\n expect(users).toHaveLength(10);\n});\n</code></pre></p> <p>Bad: <pre><code>it('should fetch users', () => {\n userService.getUsers().then(users => {\n expect(users).toHaveLength(10); // \u274c May not run\n });\n});\n</code></pre></p>"},{"location":"v2/development/testing/#coverage-requirements","title":"Coverage Requirements","text":"<p>Target coverage thresholds:</p> <pre><code>// jest.config.js / vitest.config.ts\ncoverageThreshold: {\n global: {\n branches: 80,\n functions: 80,\n lines: 80,\n statements: 80\n }\n}\n</code></pre> <p>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</p>"},{"location":"v2/development/testing/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/development/testing/#tests-timing-out","title":"Tests Timing Out","text":"<p>Problem: Tests exceed timeout.</p> <p>Solution:</p> <pre><code>// 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</code></pre>"},{"location":"v2/development/testing/#mocks-not-working","title":"Mocks Not Working","text":"<p>Problem: Mocks not being used.</p> <p>Solution:</p> <pre><code>// 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</code></pre>"},{"location":"v2/development/testing/#database-connection-errors","title":"Database Connection Errors","text":"<p>Problem: Tests fail with DB connection errors.</p> <p>Solution:</p> <pre><code>// Use separate test database\nprocess.env.DATABASE_URL = 'postgresql://localhost/test_db';\n\n// Or mock database entirely\nvi.mock('@prisma/client');\n</code></pre>"},{"location":"v2/development/testing/#react-testing-library-queries-failing","title":"React Testing Library Queries Failing","text":"<p>Problem: <code>screen.getByText()</code> doesn't find element.</p> <p>Solution:</p> <pre><code>// 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</code></pre>"},{"location":"v2/development/testing/#related-documentation","title":"Related Documentation","text":"<ul> <li>Setup: Local Development Setup</li> <li>Code Style: Code Style Guide</li> <li>TypeScript: TypeScript Guide</li> <li>Debugging: Debugging Guide</li> <li>CI/CD: Deployment Guide</li> </ul>"},{"location":"v2/development/testing/#summary","title":"Summary","text":"<p>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</p> <p>Quick Start: <pre><code># 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</code></pre></p>"},{"location":"v2/development/typescript/","title":"TypeScript Best Practices","text":"<p>Comprehensive TypeScript guide for Changemaker Lite V2, covering type system fundamentals, common patterns, and V2-specific conventions.</p>"},{"location":"v2/development/typescript/#overview","title":"Overview","text":"<p>Changemaker Lite V2 uses TypeScript 5.x with strict mode enabled for maximum type safety.</p> <p>Benefits: - Catch errors at compile time - Better IDE autocomplete and refactoring - Self-documenting code - Safer refactoring</p> <p>This guide covers TypeScript best practices specific to V2 development.</p>"},{"location":"v2/development/typescript/#type-system-fundamentals","title":"Type System Fundamentals","text":""},{"location":"v2/development/typescript/#primitives","title":"Primitives","text":"<pre><code>// 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</code></pre>"},{"location":"v2/development/typescript/#objects","title":"Objects","text":"<pre><code>// 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</code></pre>"},{"location":"v2/development/typescript/#functions","title":"Functions","text":"<pre><code>// 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</code></pre>"},{"location":"v2/development/typescript/#unions-and-intersections","title":"Unions and Intersections","text":"<pre><code>// 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</code></pre>"},{"location":"v2/development/typescript/#generics","title":"Generics","text":"<pre><code>// 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</code></pre>"},{"location":"v2/development/typescript/#utility-types","title":"Utility Types","text":"<pre><code>// 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</code></pre>"},{"location":"v2/development/typescript/#common-v2-patterns","title":"Common V2 Patterns","text":""},{"location":"v2/development/typescript/#requestresponse-types","title":"Request/Response Types","text":"<p>API Request:</p> <pre><code>// 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</code></pre> <p>Augmented Request (with user from JWT):</p> <pre><code>// 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</code></pre>"},{"location":"v2/development/typescript/#prisma-types","title":"Prisma Types","text":"<p>Generated Types:</p> <pre><code>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</code></pre> <p>JSON Fields:</p> <pre><code>// 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</code></pre>"},{"location":"v2/development/typescript/#drizzle-types","title":"Drizzle Types","text":"<p>Schema Types:</p> <pre><code>// 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</code></pre>"},{"location":"v2/development/typescript/#zod-schemas","title":"Zod Schemas","text":"<p>Validation Schemas:</p> <pre><code>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</code></pre>"},{"location":"v2/development/typescript/#react-component-types","title":"React Component Types","text":"<p>Component Props:</p> <pre><code>// 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</code></pre> <p>Event Handlers:</p> <pre><code>// 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</code></pre> <p>Hooks:</p> <pre><code>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</code></pre> <p>Zustand Store:</p> <pre><code>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</code></pre>"},{"location":"v2/development/typescript/#type-safety","title":"Type Safety","text":""},{"location":"v2/development/typescript/#avoiding-any","title":"Avoiding <code>any</code>","text":"<p>Never use <code>any</code> (ESLint rule enforced):</p> <p>Bad: <pre><code>function processData(data: any) {\n return data.foo.bar; // No type safety\n}\n</code></pre></p> <p>Good: <pre><code>// 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</code></pre></p>"},{"location":"v2/development/typescript/#type-assertions","title":"Type Assertions","text":"<p>Use type assertions carefully:</p> <p>Good: <pre><code>// 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</code></pre></p> <p>Bad: <pre><code>// Dangerous: Could be wrong\nconst data = response.data as User;\n\n// Better: Validate first\nconst data = validateUser(response.data);\n</code></pre></p>"},{"location":"v2/development/typescript/#non-null-assertion","title":"Non-null Assertion","text":"<p>Use <code>!</code> only when TypeScript can't infer non-null:</p> <p>Good: <pre><code>// 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</code></pre></p> <p>Bad: <pre><code>// Dangerous: Could be null\nconst user = await prisma.user.findUnique({ where: { id: 1 } });\nconsole.log(user!.email); // Could crash if user is null\n</code></pre></p>"},{"location":"v2/development/typescript/#type-guards","title":"Type Guards","text":"<p>Create type guards for runtime validation:</p> <pre><code>// 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</code></pre>"},{"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":"<p>Problem: <code>req.params.id</code> type is <code>string | string[]</code> in Express 5.</p> <p>Solution:</p> <pre><code>// 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</code></pre>"},{"location":"v2/development/typescript/#useref-with-undefined","title":"useRef with Undefined","text":"<p>Problem: <code>useRef<T>()</code> requires explicit undefined.</p> <p>Solution:</p> <pre><code>// Good\nconst ref = useRef<HTMLInputElement>(undefined);\nconst ref = useRef<HTMLInputElement | null>(null);\n\n// Bad\nconst ref = useRef<HTMLInputElement>(); // Type error\n</code></pre>"},{"location":"v2/development/typescript/#prisma-json-fields","title":"Prisma JSON Fields","text":"<p>Problem: JSON arrays need cast.</p> <p>Solution:</p> <pre><code>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</code></pre>"},{"location":"v2/development/typescript/#mixing-and","title":"Mixing ?? and ||","text":"<p>Problem: Cannot mix <code>??</code> and <code>||</code> without parentheses.</p> <p>Solution:</p> <pre><code>// Error\nconst value = a ?? b || c;\n\n// Good\nconst value = (a ?? b) || c;\nconst value = a ?? (b || c);\n</code></pre>"},{"location":"v2/development/typescript/#record-cast","title":"Record Cast <p>Problem: Need to cast via <code>unknown</code> first.</p> <p>Solution:</p> <pre><code>// Error\nconst obj: Record<string, unknown> = someData;\n\n// Good\nconst obj = someData as unknown as Record<string, unknown>;\n</code></pre>","text":""},{"location":"v2/development/typescript/#dayjs-via-ant-design","title":"dayjs via Ant Design <p>Problem: dayjs available transitively.</p> <p>Solution:</p> <pre><code>// No need to install dayjs separately\nimport dayjs from 'dayjs'; // Available via antd\n</code></pre>","text":""},{"location":"v2/development/typescript/#requser-name-field","title":"req.user Name Field <p>Problem: JWT only has <code>id</code>, <code>email</code>, <code>role</code> (no <code>name</code>).</p> <p>Solution:</p> <pre><code>// 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</code></pre>","text":""},{"location":"v2/development/typescript/#api-import-pattern","title":"API Import Pattern <p>Problem: Named export, not default.</p> <p>Solution:</p> <pre><code>// Good\nimport { api } from '../lib/api';\n\n// Bad\nimport api from '../lib/api'; // Error\n</code></pre>","text":""},{"location":"v2/development/typescript/#unchecked-createupdate","title":"Unchecked Create/Update <p>Problem: Setting foreign keys directly.</p> <p>Solution:</p> <pre><code>// 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</code></pre>","text":""},{"location":"v2/development/typescript/#type-utilities","title":"Type Utilities","text":""},{"location":"v2/development/typescript/#custom-utility-types","title":"Custom Utility Types <pre><code>// 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</code></pre>","text":""},{"location":"v2/development/typescript/#type-extraction","title":"Type Extraction <pre><code>// 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</code></pre>","text":""},{"location":"v2/development/typescript/#performance","title":"Performance","text":""},{"location":"v2/development/typescript/#build-times","title":"Build Times <p>Optimize tsconfig.json:</p> <pre><code>{\n \"compilerOptions\": {\n \"skipLibCheck\": true, // Skip type checking node_modules\n \"incremental\": true, // Enable incremental compilation\n \"tsBuildInfoFile\": \".tsbuildinfo\" // Cache file\n }\n}\n</code></pre> <p>Type-check without emit:</p> <pre><code># Faster than full build\nnpx tsc --noEmit\n</code></pre>","text":""},{"location":"v2/development/typescript/#type-inference","title":"Type Inference <p>Let TypeScript infer when possible:</p> <p>Good: <pre><code>// 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</code></pre></p> <p>Bad: <pre><code>// Unnecessary explicit type\nconst emails: string[] = users.map(u => u.email);\n</code></pre></p>","text":""},{"location":"v2/development/typescript/#migration-from-javascript","title":"Migration from JavaScript","text":""},{"location":"v2/development/typescript/#gradual-typing","title":"Gradual Typing <p>Add types incrementally:</p> <p>Step 1: Allow implicit any</p> <pre><code>{\n \"compilerOptions\": {\n \"noImplicitAny\": false\n }\n}\n</code></pre> <p>Step 2: Add types to new code</p> <pre><code>// New functions with types\nfunction createUser(email: string, password: string): User {\n // ...\n}\n</code></pre> <p>Step 3: Add types to existing code</p> <pre><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</code></pre> <p>Step 4: Enable strict mode</p> <pre><code>{\n \"compilerOptions\": {\n \"strict\": true\n }\n}\n</code></pre>","text":""},{"location":"v2/development/typescript/#related-documentation","title":"Related Documentation","text":"<ul> <li>Code Style: Code Style Guide</li> <li>Testing: Testing Guide</li> <li>API: API Architecture</li> <li>Frontend: Frontend Architecture</li> </ul>"},{"location":"v2/development/typescript/#summary","title":"Summary","text":"<p>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</p> <p>Quick Reference: <pre><code>// 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</code></pre></p>"},{"location":"v2/features/","title":"Feature Documentation","text":"<p>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.</p>"},{"location":"v2/features/#documentation-structure","title":"Documentation Structure","text":"<p>Each feature guide includes:</p> <ul> <li>Architecture diagrams showing data flow</li> <li>Database models with relationships</li> <li>API endpoints for admin and public access</li> <li>Configuration options and environment variables</li> <li>Workflows for admin, public, and volunteer users</li> <li>Code examples from actual source files</li> <li>Troubleshooting common issues</li> <li>Performance optimization tips</li> </ul>"},{"location":"v2/features/#feature-categories","title":"Feature Categories","text":""},{"location":"v2/features/#influence-features","title":"Influence Features","text":"<p>Email advocacy campaigns and representative outreach:</p> <ul> <li>Campaign Management \u2014 Create and manage advocacy campaigns</li> <li>Representative Lookup \u2014 Postal code-based representative search</li> <li>Response Wall \u2014 Public response submission and moderation</li> <li>Email Queue \u2014 BullMQ email processing system</li> <li>Postal Code Cache \u2014 Postal code geocoding cache</li> </ul>"},{"location":"v2/features/#map-features","title":"Map Features","text":"<p>Geographic location management and canvassing:</p> <ul> <li>Location Management \u2014 Building and unit-level address management</li> <li>Geocoding \u2014 Multi-provider geocoding service</li> <li>Geographic Cuts \u2014 Polygon overlays for organizing locations</li> <li>Volunteer Shifts \u2014 Shift scheduling and signup system</li> <li>Canvassing System \u2014 Door-to-door canvassing with GPS</li> <li>GPS Tracking \u2014 Real-time volunteer location tracking</li> <li>Walk Sheets \u2014 Printable canvassing materials</li> <li>Data Quality Dashboard \u2014 Geocoding quality monitoring</li> <li>NAR Import \u2014 Canadian electoral data import</li> </ul>"},{"location":"v2/features/#landing-pages","title":"Landing Pages","text":"<p>Website page building and management:</p> <ul> <li>Page Builder \u2014 GrapesJS visual editor</li> <li>GrapesJS Editor Component \u2014 Editor integration</li> <li>Block Library \u2014 Reusable content blocks</li> <li>MkDocs Export \u2014 Export to documentation site</li> </ul>"},{"location":"v2/features/#email-templates","title":"Email Templates","text":"<p>Email template system for campaigns:</p> <ul> <li>Template System \u2014 Email template engine</li> <li>Template Editor \u2014 HTML template editing</li> <li>Template Variables \u2014 Dynamic variable system</li> <li>Version History \u2014 Template version tracking</li> </ul>"},{"location":"v2/features/#media-features","title":"Media Features","text":"<p>Video library management:</p> <ul> <li>Video Library \u2014 Video organization and metadata</li> <li>Video Upload \u2014 Upload with automatic metadata extraction</li> <li>Media Jobs \u2014 Background job processing</li> <li>Public Gallery \u2014 Public video sharing</li> </ul>"},{"location":"v2/features/#newsletter-integration","title":"Newsletter Integration","text":"<p>Listmonk newsletter platform integration:</p> <ul> <li>Listmonk Integration \u2014 API client setup</li> <li>Data Sync \u2014 Sync contacts to Listmonk</li> <li>List Management \u2014 Newsletter list administration</li> </ul>"},{"location":"v2/features/#tunnel-management","title":"Tunnel Management","text":"<p>Pangolin tunnel for public access:</p> <ul> <li>Pangolin Setup \u2014 Tunnel configuration</li> <li>Newt Container \u2014 Docker integration</li> <li>Exit Nodes \u2014 Exit node management</li> </ul>"},{"location":"v2/features/#observability","title":"Observability","text":"<p>Monitoring and metrics:</p> <ul> <li>Prometheus Metrics \u2014 Custom metrics collection</li> <li>Grafana Dashboards \u2014 Visualization dashboards</li> <li>Alertmanager \u2014 Alert routing</li> <li>Data Quality Monitoring \u2014 Data quality tracking</li> </ul>"},{"location":"v2/features/#related-documentation","title":"Related Documentation","text":"<ul> <li>Backend Modules \u2014 API implementation details</li> <li>Frontend Pages \u2014 UI component documentation</li> <li>Database Models \u2014 Schema documentation</li> <li>Architecture \u2014 System architecture guides</li> <li>User Guides \u2014 Step-by-step tutorials</li> </ul>"},{"location":"v2/features/#quick-navigation","title":"Quick Navigation","text":""},{"location":"v2/features/#by-user-role","title":"By User Role","text":"<p>Administrators: - Campaign creation and management - Response moderation - User management - Location management - Shift scheduling - Email queue monitoring - Landing page editing</p> <p>Public Users: - Campaign participation - Representative lookup - Email sending - Response submission - Shift signup - Media gallery browsing</p> <p>Volunteers: - Canvassing with GPS - Visit recording - Shift assignments - Activity tracking - Route history</p>"},{"location":"v2/features/#by-use-case","title":"By Use Case","text":"<p>Advocacy Campaigns: 1. Create campaign 2. Configure representatives 3. Monitor email queue 4. Moderate responses</p> <p>Canvassing Operations: 1. Import locations 2. Create geographic cuts 3. Schedule shifts 4. Track canvassing 5. Print walk sheets</p> <p>Website Management: 1. Build landing pages 2. Manage content blocks 3. Export to MkDocs</p> <p>Public Access: 1. Setup Pangolin tunnel 2. Configure Newt container 3. Monitor with observability</p>"},{"location":"v2/features/COMPLETION_STATUS/","title":"Phase 6 Features Documentation - Completion Status","text":""},{"location":"v2/features/COMPLETION_STATUS/#overview","title":"Overview","text":"<p>Phase 6 creates comprehensive end-to-end feature documentation showing how complete features work across backend + frontend + database layers.</p> <p>Target: 26 feature documentation files Created: 6 files (23%) Remaining: 20 files (77%)</p>"},{"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":"<p>\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</p> <p>\u274c call-tracking.md - Phone call tracking (not yet implemented in codebase)</p>"},{"location":"v2/features/COMPLETION_STATUS/#core-features-11","title":"Core Features (1/1)","text":"<p>\u2705 index.md (155 lines) - Features documentation index with navigation</p>"},{"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":"<p>\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)</p>"},{"location":"v2/features/COMPLETION_STATUS/#landing-pages-features-04","title":"Landing Pages Features (0/4)","text":"<p>\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)</p>"},{"location":"v2/features/COMPLETION_STATUS/#email-templates-features-04","title":"Email Templates Features (0/4)","text":"<p>\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)</p>"},{"location":"v2/features/COMPLETION_STATUS/#media-features-04","title":"Media Features (0/4)","text":"<p>\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)</p>"},{"location":"v2/features/COMPLETION_STATUS/#newsletter-features-03","title":"Newsletter Features (0/3)","text":"<p>\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)</p>"},{"location":"v2/features/COMPLETION_STATUS/#tunnel-features-03","title":"Tunnel Features (0/3)","text":"<p>\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)</p>"},{"location":"v2/features/COMPLETION_STATUS/#observability-features-04","title":"Observability Features (0/4)","text":"<p>\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)</p>"},{"location":"v2/features/COMPLETION_STATUS/#file-structure-template","title":"File Structure Template","text":"<p>Each feature file should follow this 12-section structure:</p> <ol> <li>Overview \u2014 Feature purpose, use cases, key capabilities</li> <li>Architecture \u2014 Mermaid diagram showing frontend \u2192 API \u2192 service \u2192 database flow</li> <li>Database Models \u2014 Related models with links to database docs</li> <li>API Endpoints \u2014 List of endpoints with links to API reference docs</li> <li>Configuration \u2014 Environment variables, settings, feature flags (table format)</li> <li>Admin Workflow \u2014 Step-by-step guide for administrators</li> <li>Public Workflow \u2014 Step-by-step guide for public users (if applicable)</li> <li>Volunteer Workflow \u2014 Step-by-step guide for volunteers (if applicable)</li> <li>Code Examples \u2014 Real code snippets from backend/frontend</li> <li>Troubleshooting \u2014 Common issues + solutions</li> <li>Performance Considerations \u2014 Optimization tips, scaling notes</li> <li>Related Documentation \u2014 Links to backend modules, frontend pages, database models</li> </ol>"},{"location":"v2/features/COMPLETION_STATUS/#source-references","title":"Source References","text":"<p>Completed Files Reference:</p> <ul> <li><code>api/src/modules/influence/campaigns/</code> \u2192 campaigns.md</li> <li><code>api/src/modules/influence/representatives/</code> \u2192 representatives.md</li> <li><code>api/src/modules/influence/responses/</code> \u2192 responses.md</li> <li><code>api/src/services/email-queue.service.ts</code> \u2192 email-queue.md</li> <li><code>admin/src/pages/CampaignsPage.tsx</code> \u2192 campaigns.md</li> <li><code>admin/src/pages/ResponsesPage.tsx</code> \u2192 responses.md</li> </ul> <p>For Remaining Files:</p> <ul> <li><code>api/src/modules/map/locations/</code> \u2192 locations.md</li> <li><code>api/src/modules/map/geocoding/</code> \u2192 geocoding.md</li> <li><code>api/src/modules/map/cuts/</code> \u2192 cuts.md</li> <li><code>api/src/modules/map/shifts/</code> \u2192 shifts.md</li> <li><code>api/src/modules/map/canvass/</code> \u2192 canvassing.md</li> <li><code>api/src/modules/map/tracking/</code> \u2192 tracking.md</li> <li><code>api/src/modules/pages/</code> \u2192 page-builder.md, block-library.md</li> <li><code>api/src/modules/email-templates/</code> \u2192 template-system.md, editor.md, variables.md, versioning.md</li> <li><code>api/src/modules/media/</code> \u2192 video-library.md, upload.md, jobs.md, public-gallery.md</li> <li><code>api/src/services/listmonk.client.ts</code> \u2192 listmonk-integration.md</li> <li><code>api/src/services/pangolin.client.ts</code> \u2192 pangolin-setup.md</li> <li><code>api/src/utils/metrics.ts</code> \u2192 prometheus-metrics.md</li> </ul>"},{"location":"v2/features/COMPLETION_STATUS/#statistics","title":"Statistics","text":"<p>Total Lines Created: ~4,530 lines across 6 files Average File Size: ~755 lines Estimated Remaining: ~15,100 lines (20 files \u00d7 755 avg) Total Target: ~19,630 lines across 26 files</p>"},{"location":"v2/features/COMPLETION_STATUS/#next-steps","title":"Next Steps","text":"<ol> <li>Create map features (highest priority - core platform functionality)</li> <li>Create landing pages features (GrapesJS integration)</li> <li>Create media features (video library + upload)</li> <li>Create email templates features</li> <li>Create newsletter features</li> <li>Create tunnel features</li> <li>Create observability features</li> </ol>"},{"location":"v2/features/COMPLETION_STATUS/#notes","title":"Notes","text":"<ul> <li>All completed files include comprehensive Mermaid architecture diagrams</li> <li>Real code examples extracted from source files (not invented)</li> <li>Cross-references to Phase 3 (backend modules), Phase 4 (frontend pages), Phase 5 (database models)</li> <li>Configuration tables with all environment variables</li> <li>Troubleshooting sections with common errors and solutions</li> <li>Performance considerations with optimization tips</li> </ul>"},{"location":"v2/features/email-templates/","title":"Email Templates","text":"<p>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.</p>"},{"location":"v2/features/email-templates/#overview","title":"Overview","text":"<p>The Email Templates system consists of four integrated components:</p> <ol> <li>Template System - Template CRUD and management</li> <li>Editor - Rich text editor with variable insertion</li> <li>Variables - Dynamic content placeholders</li> <li>Versioning - Template version history</li> </ol>"},{"location":"v2/features/email-templates/#features","title":"Features","text":""},{"location":"v2/features/email-templates/#template-management","title":"Template Management","text":"<ul> <li>Create/edit/delete templates</li> <li>Category organization</li> <li>Template types (campaign, notification, system)</li> <li>Published/draft status</li> <li>Search and filtering</li> <li>Clone templates</li> </ul>"},{"location":"v2/features/email-templates/#rich-text-editor","title":"Rich Text Editor","text":"<ul> <li>WYSIWYG HTML editor</li> <li>Variable insertion menu</li> <li>Preview mode</li> <li>HTML source view</li> <li>Image upload (future)</li> <li>Link management</li> </ul>"},{"location":"v2/features/email-templates/#variable-system","title":"Variable System","text":"<p>Dynamic placeholders:</p> <ul> <li>User variables - <code>{{user.name}}</code>, <code>{{user.email}}</code></li> <li>Campaign variables - <code>{{campaign.name}}</code>, <code>{{campaign.description}}</code></li> <li>Representative variables - <code>{{rep.name}}</code>, <code>{{rep.title}}</code>, <code>{{rep.email}}</code></li> <li>Custom variables - Template-specific placeholders</li> <li>System variables - <code>{{site.name}}</code>, <code>{{current.date}}</code></li> </ul>"},{"location":"v2/features/email-templates/#version-history","title":"Version History","text":"<ul> <li>Auto-save on changes</li> <li>Version diff viewer</li> <li>Restore previous versions</li> <li>Change log</li> </ul>"},{"location":"v2/features/email-templates/#user-flow","title":"User Flow","text":""},{"location":"v2/features/email-templates/#admin-experience","title":"Admin Experience","text":"<ol> <li>Create Template (<code>/app/email-templates</code>)</li> <li>Click \"New Template\"</li> <li>Enter name and category</li> <li>Set template type</li> <li> <p>Save draft</p> </li> <li> <p>Edit Template (<code>/app/email-templates/:id/edit</code>)</p> </li> <li>Full-screen rich text editor</li> <li>Insert variables from dropdown</li> <li>Preview with sample data</li> <li> <p>Save changes</p> </li> <li> <p>Use Template</p> </li> <li>Select template in campaign form</li> <li>Variables auto-populated from context</li> <li> <p>Send email with processed template</p> </li> <li> <p>Manage Versions (<code>/app/email-templates/:id/versions</code>)</p> </li> <li>View version history</li> <li>Compare versions</li> <li>Restore previous version</li> </ol>"},{"location":"v2/features/email-templates/#architecture","title":"Architecture","text":""},{"location":"v2/features/email-templates/#backend-components","title":"Backend Components","text":"<p>Module: - <code>api/src/modules/email-templates/email-templates.routes.ts</code> - Template CRUD - <code>api/src/modules/email-templates/email-templates.service.ts</code> - Business logic - <code>api/src/modules/email-templates/email-templates.schemas.ts</code> - Zod validation</p> <p>Database Models: - <code>EmailTemplate</code> - Template definitions (name, content, variables) - <code>EmailTemplateVersion</code> - Version history (future)</p> <p>Email Processing: - Variable substitution in <code>email.service.ts</code> - Mustache-style templating: <code>{{variable}}</code> - HTML escaping for security</p>"},{"location":"v2/features/email-templates/#frontend-components","title":"Frontend Components","text":"<p>Admin Pages: - <code>admin/src/pages/EmailTemplatesPage.tsx</code> - Template management table - <code>admin/src/pages/EmailTemplateEditorPage.tsx</code> - Full-screen editor</p> <p>Editor Components: - <code>admin/src/components/email-templates/TemplateEditor.tsx</code> - Rich text editor - <code>admin/src/components/email-templates/VariableInserter.tsx</code> - Variable dropdown</p>"},{"location":"v2/features/email-templates/#configuration","title":"Configuration","text":""},{"location":"v2/features/email-templates/#template-types","title":"Template Types","text":"<ul> <li>campaign - Campaign email templates</li> <li>notification - User notifications</li> <li>system - System emails (verification, password reset)</li> <li>custom - Custom templates</li> </ul>"},{"location":"v2/features/email-templates/#template-categories","title":"Template Categories","text":"<ul> <li>Influence - Campaign-related templates</li> <li>Map - Shift/canvass notifications</li> <li>User - User account emails</li> <li>System - Automated system emails</li> </ul>"},{"location":"v2/features/email-templates/#variable-system_1","title":"Variable System","text":""},{"location":"v2/features/email-templates/#available-variables","title":"Available Variables","text":"<p>User Context: <pre><code>{{user.id}} # User ID\n{{user.email}} # Email address\n{{user.name}} # Full name\n{{user.role}} # User role\n</code></pre></p> <p>Campaign Context: <pre><code>{{campaign.id}} # Campaign ID\n{{campaign.name}} # Campaign name\n{{campaign.description}} # Description\n{{campaign.emailTemplate}} # Email body\n</code></pre></p> <p>Representative Context: <pre><code>{{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</code></pre></p> <p>System Context: <pre><code>{{site.name}} # Site name\n{{site.url}} # Site URL\n{{current.date}} # Current date\n{{current.year}} # Current year\n</code></pre></p>"},{"location":"v2/features/email-templates/#variable-insertion","title":"Variable Insertion","text":"<pre><code>// 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</code></pre>"},{"location":"v2/features/email-templates/#variable-processing","title":"Variable Processing","text":"<p>Server-side processing in <code>email.service.ts</code>:</p> <pre><code>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</code></pre>"},{"location":"v2/features/email-templates/#database-schema","title":"Database Schema","text":""},{"location":"v2/features/email-templates/#emailtemplate-model","title":"EmailTemplate Model","text":"<pre><code>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</code></pre>"},{"location":"v2/features/email-templates/#api-endpoints","title":"API Endpoints","text":""},{"location":"v2/features/email-templates/#admin-endpoints","title":"Admin Endpoints","text":"<pre><code>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</code></pre>"},{"location":"v2/features/email-templates/#security","title":"Security","text":""},{"location":"v2/features/email-templates/#html-escaping","title":"HTML Escaping","text":"<p>All variable values are HTML-escaped to prevent XSS:</p> <pre><code>import { escapeHtml } from '../utils/sanitize';\n\nconst safe = escapeHtml(userInput);\n// Converts: < > & \" ' to HTML entities\n</code></pre>"},{"location":"v2/features/email-templates/#template-validation","title":"Template Validation","text":"<ul> <li>Subject line: 1-200 characters</li> <li>Body: Required, max 50,000 characters</li> <li>Variables: Valid JSON object</li> <li>Category: Predefined list</li> </ul>"},{"location":"v2/features/email-templates/#best-practices","title":"Best Practices","text":""},{"location":"v2/features/email-templates/#template-design","title":"Template Design","text":"<ul> <li>Clear subject lines (50-60 chars)</li> <li>Personalize with variables</li> <li>Mobile-responsive HTML</li> <li>Plain text alternative</li> <li>Unsubscribe link</li> <li>Branding consistency</li> </ul>"},{"location":"v2/features/email-templates/#variable-usage","title":"Variable Usage","text":"<ul> <li>Document available variables</li> <li>Provide defaults for missing values</li> <li>Test with sample data</li> <li>Validate variable names</li> </ul>"},{"location":"v2/features/email-templates/#version-management","title":"Version Management","text":"<ul> <li>Meaningful version names</li> <li>Document changes</li> <li>Test before publishing</li> <li>Keep version history</li> </ul>"},{"location":"v2/features/email-templates/#desktop-only-editor","title":"Desktop-Only Editor","text":"<p>Email template editor requires desktop browser:</p> <pre><code>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</code></pre>"},{"location":"v2/features/email-templates/#integration-points","title":"Integration Points","text":""},{"location":"v2/features/email-templates/#campaign-emails","title":"Campaign Emails","text":"<p>Campaign emails use templates:</p> <pre><code>// 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</code></pre>"},{"location":"v2/features/email-templates/#system-emails","title":"System Emails","text":"<p>System emails (verification, password reset):</p> <pre><code>// 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</code></pre>"},{"location":"v2/features/email-templates/#related-documentation","title":"Related Documentation","text":"<ul> <li>Template System</li> <li>Editor</li> <li>Variables</li> <li>Versioning</li> <li>Email Templates Page</li> <li>Email Template Editor Page</li> <li>Email Service</li> <li>Campaign Manager Guide</li> </ul>"},{"location":"v2/features/email-templates/editor/","title":"Email Template Editor","text":""},{"location":"v2/features/email-templates/editor/#overview","title":"Overview","text":"<p>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.</p> <p>Key Features:</p> <ul> <li>Split-Pane Editor \u2014 Side-by-side HTML and text editing</li> <li>Variable Insertion Buttons \u2014 Click to insert {{VARIABLES}} at cursor position</li> <li>Live Preview Rendering \u2014 See rendered HTML with sample data in real-time</li> <li>Test Send Functionality \u2014 Send test emails with custom sample data</li> <li>Auto-Save Drafts \u2014 Prevent data loss with automatic draft saving</li> <li>Version Creation \u2014 Every save creates a new version with change notes</li> <li>Responsive Layout \u2014 Desktop-optimized (mobile warning for small screens)</li> <li>Keyboard Shortcuts \u2014 Ctrl+S to save, Ctrl+P to preview, Esc to close</li> </ul> <p>Access Control: - Role Required: SUPER_ADMIN only - Route: <code>/app/email-templates/:id/edit</code> - Layout: Full-screen (no AppLayout sidebar)</p>"},{"location":"v2/features/email-templates/editor/#architecture","title":"Architecture","text":"<pre><code>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</code></pre> <p>Data Flow:</p> <ol> <li>Load Template \u2014 Fetch template + variables via GET API</li> <li>Restore Draft \u2014 Load from localStorage if exists (unsaved changes)</li> <li>Edit Content \u2014 Type in HTML/text editors, updates component state</li> <li>Insert Variable \u2014 Click variable button \u2192 inserts <code>{{VAR}}</code> at cursor</li> <li>Preview Update \u2014 Debounced (300ms) Handlebars compilation + iframe render</li> <li>Test Send \u2014 Enter recipient + sample data \u2192 POST to test endpoint \u2192 email sent</li> <li>Save Template \u2014 Click save \u2192 PUT API \u2192 create version \u2192 clear draft \u2192 redirect</li> <li>Auto-Save Draft \u2014 Blur event \u2192 save to localStorage (not database)</li> </ol>"},{"location":"v2/features/email-templates/editor/#editor-components","title":"Editor Components","text":""},{"location":"v2/features/email-templates/editor/#toolbar","title":"Toolbar","text":"<p>Location: Top bar (sticky)</p> <p>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)</p> <p>Actions: <pre><code>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</code></pre></p>"},{"location":"v2/features/email-templates/editor/#html-editor-pane","title":"HTML Editor Pane","text":"<p>Location: Left side (50% width) or full width when preview hidden</p> <p>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)</p> <p>Implementation: <pre><code>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</code></pre></p>"},{"location":"v2/features/email-templates/editor/#text-editor-pane","title":"Text Editor Pane","text":"<p>Location: Left side (50% width) or full width when preview hidden</p> <p>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</p> <p>Implementation: <pre><code>const [textContent, setTextContent] = useState('');\nconst textEditorRef = useRef<HTMLTextAreaElement>(null);\n\nconst handleTextChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {\n setTextContent(e.target.value);\n};\n</code></pre></p>"},{"location":"v2/features/email-templates/editor/#variable-insertion-panel","title":"Variable Insertion Panel","text":"<p>Location: Right sidebar (collapsible)</p> <p>Features: - Variable List \u2014 All template variables with labels - Insert Buttons \u2014 Click to insert <code>{{VAR}}</code> 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)</p> <p>Implementation: <pre><code>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</code></pre></p> <p>Variable List UI: <pre><code><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</code></pre></p>"},{"location":"v2/features/email-templates/editor/#live-preview-pane","title":"Live Preview Pane","text":"<p>Location: Right side (50% width) when enabled</p> <p>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</p> <p>Implementation: <pre><code>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</code></pre></p> <p>Sample Data Form: <pre><code>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</code></pre></p>"},{"location":"v2/features/email-templates/editor/#test-send-form","title":"Test Send Form","text":"<p>Location: Modal dialog</p> <p>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</p> <p>Implementation: <pre><code>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</code></pre></p>"},{"location":"v2/features/email-templates/editor/#admin-workflow","title":"Admin Workflow","text":""},{"location":"v2/features/email-templates/editor/#opening-editor","title":"Opening Editor","text":"<p>From EmailTemplatesPage:</p> <ol> <li>Click template row in table</li> <li>Opens template detail modal</li> <li>Click \"Edit\" button in modal</li> <li>Opens EmailTemplateEditorPage in same tab</li> </ol> <p>Direct URL: <pre><code>/app/email-templates/{id}/edit\n</code></pre></p> <p>Route Definition: <pre><code>// 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</code></pre></p>"},{"location":"v2/features/email-templates/editor/#editing-html-content","title":"Editing HTML Content","text":"<p>Step 1: Load Template - Template data fetched via API on component mount - HTML/text content populated in editors - Variables loaded in insertion panel</p> <p>Step 2: Edit HTML - Type HTML with <code>{{VARIABLES}}</code> placeholders - Use variable insertion buttons for convenience - Preview updates automatically (300ms debounce)</p> <p>Step 3: Insert Variables - Click variable \"Insert to HTML\" button - <code>{{VARIABLE_KEY}}</code> inserted at cursor position - Cursor moves after inserted variable</p> <p>Step 4: Preview Changes - Live preview pane shows rendered HTML - Edit sample data to test different values - Check for formatting issues</p> <p>Example Editing Session: <pre><code><!-- 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</code></pre></p>"},{"location":"v2/features/email-templates/editor/#using-variable-insertion","title":"Using Variable Insertion","text":"<p>Keyboard Method: 1. Type <code>{{</code> in HTML editor 2. Type variable name (e.g., <code>USER_NAME</code>) 3. Type <code>}}</code></p> <p>Button Method: 1. Place cursor where you want variable 2. Click variable \"Insert to HTML\" button 3. <code>{{VARIABLE_KEY}}</code> inserted at cursor 4. Cursor moves to end of insertion</p> <p>Insertion Logic: <pre><code>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</code></pre></p>"},{"location":"v2/features/email-templates/editor/#live-preview","title":"Live Preview","text":"<p>Preview Update Flow:</p> <ol> <li>Type in HTML Editor</li> <li><code>onChange</code> event fires</li> <li>Updates <code>htmlContent</code> state</li> <li> <p>Triggers debounced preview render (300ms)</p> </li> <li> <p>Debounced Render</p> </li> <li>Waits 300ms after typing stops</li> <li>Compiles Handlebars template</li> <li>Interpolates with sample data</li> <li> <p>Injects HTML into iframe</p> </li> <li> <p>Sample Data Changes</p> </li> <li>Edit sample data form fields</li> <li>Updates <code>sampleData</code> state</li> <li>Immediately triggers preview render (no debounce)</li> </ol> <p>Preview Error Handling: <pre><code>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</code></pre></p>"},{"location":"v2/features/email-templates/editor/#testing-template","title":"Testing Template","text":"<p>Step 1: Click \"Send Test\" Button - Opens test send modal</p> <p>Step 2: Enter Recipient Email - Your email address (or test account) - Validates email format before sending</p> <p>Step 3: Edit Sample Data - Pre-filled with variable sample values - Modify to test specific scenarios - Example: Set <code>HAS_PHONE</code> to <code>false</code> to test conditional block</p> <p>Step 4: Click \"Send Test\" - POST request to <code>/api/email-templates/:id/test</code> - Email sent via SMTP (or MailHog in test mode) - Success notification displayed</p> <p>Step 5: Check Email - Open email client (or MailHog at http://localhost:8025) - Verify rendering, variables, formatting - Test links, images, layout</p> <p>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</p>"},{"location":"v2/features/email-templates/editor/#saving-changes","title":"Saving Changes","text":"<p>Step 1: Click \"Save\" Button - Toolbar save button (or Ctrl+S keyboard shortcut)</p> <p>Step 2: Enter Change Notes - Modal prompts for change description - Used for version history audit trail - Optional but recommended</p> <p>Step 3: Confirm Save - PUT request to <code>/api/email-templates/:id</code> - Creates new version automatically - Clears localStorage draft - Redirects to EmailTemplatesPage</p> <p>Save Implementation: <pre><code>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</code></pre></p>"},{"location":"v2/features/email-templates/editor/#code-examples","title":"Code Examples","text":""},{"location":"v2/features/email-templates/editor/#emailtemplateeditorpage-component","title":"EmailTemplateEditorPage Component","text":"<p>Full Component Structure:</p> <pre><code>// 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</code></pre>"},{"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":"<p>Symptoms: - Type in HTML editor but preview doesn't change - Preview shows old content</p> <p>Causes: 1. Debounce timer still running (300ms delay) 2. Handlebars compilation error (silent failure) 3. Iframe not re-rendering</p> <p>Solutions:</p> <p>Wait for debounce: - Wait 300ms after typing stops - Preview should update automatically</p> <p>Check browser console: <pre><code>// Look for errors\nHandlebars.compile error: ...\n</code></pre></p> <p>Force preview update: <pre><code>// Add button to manually trigger preview\n<Button onClick={() => renderPreview(htmlContent, sampleData)}>\n Refresh Preview\n</Button>\n</code></pre></p> <p>Check iframe contentDocument: <pre><code>console.log('Iframe doc:', previewRef.current?.contentDocument);\n// Should not be null\n</code></pre></p>"},{"location":"v2/features/email-templates/editor/#problem-test-send-fails","title":"Problem: Test send fails","text":"<p>Symptoms: - \"Failed to send test email\" error - Email not received in inbox or MailHog</p> <p>Causes: 1. SMTP configuration incorrect 2. Email test mode disabled (sending to real SMTP) 3. Recipient email invalid 4. Template has compilation errors</p> <p>Solutions:</p> <p>Check SMTP settings: <pre><code># .env\nEMAIL_TEST_MODE=true # Use MailHog\n</code></pre></p> <p>Verify MailHog running: <pre><code>docker compose ps mailhog\n# Should show \"Up\"\n</code></pre></p> <p>Check test logs: <pre><code>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</code></pre></p> <p>Test with minimal template: <pre><code><p>Hello {{USER_NAME}}</p>\n</code></pre></p> <p>Validate email address: <pre><code>import validator from 'validator';\n\nif (!validator.isEmail(testRecipient)) {\n message.error('Invalid email address');\n return;\n}\n</code></pre></p>"},{"location":"v2/features/email-templates/editor/#problem-variable-insertion-doesnt-work","title":"Problem: Variable insertion doesn't work","text":"<p>Symptoms: - Click \"Insert to HTML\" button but nothing happens - Variable inserted in wrong location</p> <p>Causes: 1. Textarea ref not set 2. Cursor position not captured correctly 3. State update timing issue</p> <p>Solutions:</p> <p>Check ref exists: <pre><code>console.log('HTML ref:', htmlEditorRef.current);\n// Should be <textarea> element\n</code></pre></p> <p>Debug cursor position: <pre><code>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</code></pre></p> <p>Manual workaround: - Type <code>{{VARIABLE_KEY}}</code> manually instead of using button</p>"},{"location":"v2/features/email-templates/editor/#problem-draft-not-restored-on-reload","title":"Problem: Draft not restored on reload","text":"<p>Symptoms: - Unsaved changes lost after browser refresh - No \"Restored draft\" message</p> <p>Causes: 1. localStorage not available (private browsing) 2. Draft key mismatch 3. localStorage quota exceeded</p> <p>Solutions:</p> <p>Check localStorage: <pre><code>// Browser console\nlocalStorage.getItem('email-template-draft-cuid123');\n// Should return JSON string\n</code></pre></p> <p>Verify draft key: <pre><code>console.log('Draft key:', `email-template-draft-${id}`);\n</code></pre></p> <p>Clear old drafts: <pre><code>// 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</code></pre></p>"},{"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":"<p>Current: Basic HTML textarea Future: Monaco Editor with syntax highlighting, IntelliSense, error detection</p> <p>Benefits: - Syntax highlighting for HTML - Auto-completion for HTML tags and Handlebars syntax - Error squiggles for invalid HTML - Multi-cursor editing - Code folding</p> <p>Implementation: <pre><code>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</code></pre></p>"},{"location":"v2/features/email-templates/editor/#drag-drop-block-builder","title":"Drag-Drop Block Builder","text":"<p>Current: Manual HTML editing Future: Visual block builder (like GrapesJS)</p> <p>Benefits: - No HTML knowledge required - Pre-built email blocks (header, footer, CTA button) - Drag-drop interface - Mobile-responsive by default</p> <p>Implementation: - Use GrapesJS (same as landing page editor) - Custom blocks for email-safe components - Export to HTML for template storage</p>"},{"location":"v2/features/email-templates/editor/#email-client-previews","title":"Email Client Previews","text":"<p>Current: Single iframe preview Future: Multi-client previews (Gmail, Outlook, Apple Mail)</p> <p>Benefits: - Test rendering across email clients - Catch client-specific CSS issues - Preview dark mode rendering</p> <p>Services: - Litmus API integration - Email on Acid screenshots - Self-hosted preview using email client CSS emulation</p>"},{"location":"v2/features/email-templates/editor/#ab-testing-support","title":"A/B Testing Support","text":"<p>Current: Single template version Future: A/B testing with variant templates</p> <p>Features: - Create template variants (A, B, C) - Split traffic across variants - Track open rates, click rates - Auto-promote winning variant</p> <p>Implementation: - EmailTemplateVariant model (templateId, variantName, weight, stats) - Random variant selection on send - Tracking pixel in email HTML - Analytics dashboard</p>"},{"location":"v2/features/email-templates/editor/#performance","title":"Performance","text":""},{"location":"v2/features/email-templates/editor/#auto-save-timing","title":"Auto-Save Timing","text":"<p>Current Implementation: - Save to localStorage on blur (when focus leaves editor) - No automatic interval-based saves</p> <p>Performance Impact: - Negligible (localStorage write is < 1ms) - No network requests (local only)</p> <p>Alternative: Interval-Based Auto-Save: <pre><code>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</code></pre></p>"},{"location":"v2/features/email-templates/editor/#preview-rendering-performance","title":"Preview Rendering Performance","text":"<p>Debounce Delay: - Current: 300ms - Too short: Preview updates too frequently (distracting) - Too long: Preview feels laggy</p> <p>Handlebars Compilation: - Fast (< 1ms for typical templates) - May slow down for very large templates (> 100KB)</p> <p>Iframe Rendering: - Browser-native rendering (very fast) - No performance concerns</p> <p>Optimization for Large Templates: <pre><code>// 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</code></pre></p>"},{"location":"v2/features/email-templates/editor/#accessibility","title":"Accessibility","text":""},{"location":"v2/features/email-templates/editor/#keyboard-shortcuts","title":"Keyboard Shortcuts","text":"<p>Implemented: - <code>Ctrl+S</code> (or <code>Cmd+S</code> on Mac) \u2014 Save template - <code>Ctrl+P</code> \u2014 Toggle preview pane - <code>Esc</code> \u2014 Close modal</p> <p>Implementation: <pre><code>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</code></pre></p>"},{"location":"v2/features/email-templates/editor/#screen-reader-support","title":"Screen Reader Support","text":"<p>Form Labels: <pre><code><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</code></pre></p> <p>Button Descriptions: <pre><code><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</code></pre></p>"},{"location":"v2/features/email-templates/editor/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/features/email-templates/editor/#frontend-documentation","title":"Frontend Documentation","text":"<ul> <li>EmailTemplatesPage.tsx \u2014 Email templates list page</li> <li>App.tsx \u2014 Route definition for editor page</li> </ul>"},{"location":"v2/features/email-templates/editor/#backend-documentation","title":"Backend Documentation","text":"<ul> <li>Email Templates Module \u2014 API routes</li> <li><code>GET /api/email-templates/:id</code> \u2014 Load template + variables</li> <li><code>PUT /api/email-templates/:id</code> \u2014 Update template (creates version)</li> <li><code>POST /api/email-templates/:id/test</code> \u2014 Send test email</li> </ul>"},{"location":"v2/features/email-templates/editor/#feature-documentation","title":"Feature Documentation","text":"<ul> <li>template-system.md \u2014 Email template engine overview</li> <li>variables.md \u2014 Template variable system</li> <li>versioning.md \u2014 Template version history</li> </ul>"},{"location":"v2/features/email-templates/editor/#related-features","title":"Related Features","text":"<ul> <li>Landing Page Editor \u2014 Similar GrapesJS editor for pages</li> <li>Campaign Emails \u2014 Uses email templates for advocacy emails</li> </ul>"},{"location":"v2/features/email-templates/template-system/","title":"Email Template System","text":""},{"location":"v2/features/email-templates/template-system/#overview","title":"Overview","text":"<p>The Email Template System provides centralized management of all transactional and campaign emails sent by Changemaker Lite. It enables administrators to create, edit, and maintain email templates with variable interpolation, version control, and testing capabilities.</p> <p>Key Features:</p> <ul> <li>Centralized Management \u2014 All email templates stored in database, editable via admin GUI</li> <li>Variable Interpolation \u2014 <code>{{VAR}}</code> syntax powered by Handlebars template engine</li> <li>Three Categories \u2014 INFLUENCE (campaign emails), MAP (shift/canvass emails), SYSTEM (platform emails)</li> <li>Dual Format Support \u2014 HTML + plain text versions for all templates</li> <li>System Templates \u2014 Protected templates with deletion prevention for critical platform emails</li> <li>Version Control \u2014 Automatic version history on every save with rollback capability</li> <li>Test Send \u2014 Preview rendered emails before deploying to production</li> <li>Variable Validation \u2014 Required vs optional variables with runtime validation</li> </ul> <p>Use Cases:</p> <ul> <li>Advocacy Campaigns \u2014 Custom email templates for representative outreach</li> <li>Shift Notifications \u2014 Confirmation and reminder emails for volunteer shifts</li> <li>User Onboarding \u2014 Welcome emails, verification emails, password resets</li> <li>Response Moderation \u2014 Notification emails when responses are approved/rejected</li> <li>Canvass Summaries \u2014 End-of-session reports sent to volunteers</li> </ul>"},{"location":"v2/features/email-templates/template-system/#architecture","title":"Architecture","text":"<pre><code>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</code></pre> <p>Component Responsibilities:</p> <ul> <li>EmailService \u2014 Core email sending logic with template loading and interpolation</li> <li>EmailTemplate \u2014 Template metadata (key, name, category, content, active status)</li> <li>EmailTemplateVariable \u2014 Variable definitions (key, label, required/optional, sample values)</li> <li>EmailTemplateVersion \u2014 Version history snapshots with change notes</li> <li>EmailTemplateTestLog \u2014 Test send audit trail with success/failure logging</li> <li>Handlebars Engine \u2014 Template compilation and variable interpolation</li> <li>Nodemailer \u2014 SMTP transport for production email delivery</li> <li>MailHog \u2014 Development email capture (when EMAIL_TEST_MODE=true)</li> </ul>"},{"location":"v2/features/email-templates/template-system/#database-models","title":"Database Models","text":""},{"location":"v2/features/email-templates/template-system/#emailtemplate","title":"EmailTemplate","text":"<p>Core template storage with metadata and content.</p> Field Type Description <code>id</code> String (CUID) Primary key <code>key</code> String (unique) Programmatic identifier (e.g., \"shift-signup-confirmation\") <code>name</code> String Display name for admin GUI <code>description</code> String (optional) Template purpose and usage notes <code>category</code> Enum INFLUENCE, MAP, or SYSTEM <code>subjectLine</code> String Email subject (supports {{VARIABLES}}) <code>htmlContent</code> Text HTML email body with Handlebars syntax <code>textContent</code> Text Plain text fallback version <code>isSystem</code> Boolean If true, cannot be deleted (critical platform emails) <code>isActive</code> Boolean If false, template is disabled and won't send <code>createdAt</code> DateTime Creation timestamp <code>updatedAt</code> DateTime Last modification timestamp <code>createdByUserId</code> String (optional) User who created template <code>updatedByUserId</code> String (optional) User who last modified template <p>Relations: - <code>variables</code> \u2014 EmailTemplateVariable[] (1:N) - <code>versions</code> \u2014 EmailTemplateVersion[] (1:N) - <code>testLogs</code> \u2014 EmailTemplateTestLog[] (1:N)</p> <p>Indexes: - Unique index on <code>key</code> for fast lookups - Index on <code>category</code> for filtered queries - Index on <code>isActive</code> for production template queries</p>"},{"location":"v2/features/email-templates/template-system/#emailtemplatevariable","title":"EmailTemplateVariable","text":"<p>Variable definitions for template interpolation.</p> Field Type Description <code>id</code> String (CUID) Primary key <code>templateId</code> String Foreign key to EmailTemplate <code>key</code> String Variable name (e.g., \"USER_NAME\") <code>label</code> String Display label for admin GUI <code>description</code> String (optional) Variable purpose and usage notes <code>isRequired</code> Boolean If true, must be provided in data object <code>isConditional</code> Boolean If true, used in {{#if}} blocks (truthy/falsy) <code>sampleValue</code> String (optional) Example value for testing and preview <code>sortOrder</code> Int Display order in editor variable panel <code>createdAt</code> DateTime Creation timestamp <p>Relations: - <code>template</code> \u2014 EmailTemplate (N:1)</p> <p>Constraints: - Unique index on <code>(templateId, key)</code> to prevent duplicate variables</p>"},{"location":"v2/features/email-templates/template-system/#emailtemplateversion","title":"EmailTemplateVersion","text":"<p>Version history snapshots for audit trail and rollback.</p> Field Type Description <code>id</code> String (CUID) Primary key <code>templateId</code> String Foreign key to EmailTemplate <code>versionNumber</code> Int Auto-incremented version number (1, 2, 3...) <code>subjectLine</code> String Subject at time of version <code>htmlContent</code> Text HTML content snapshot <code>textContent</code> Text Plain text content snapshot <code>changeNotes</code> String (optional) Admin-provided change description <code>createdByUserId</code> String (optional) User who created this version <code>createdAt</code> DateTime Version creation timestamp <p>Relations: - <code>template</code> \u2014 EmailTemplate (N:1) - <code>createdBy</code> \u2014 User (N:1)</p> <p>Constraints: - Unique index on <code>(templateId, versionNumber)</code> for version lookup - Auto-increment logic in service layer (finds max + 1)</p>"},{"location":"v2/features/email-templates/template-system/#emailtemplatetestlog","title":"EmailTemplateTestLog","text":"<p>Test send audit trail for debugging and compliance.</p> Field Type Description <code>id</code> String (CUID) Primary key <code>templateId</code> String Foreign key to EmailTemplate <code>recipientEmail</code> String Email address test was sent to <code>testData</code> JSON Sample variable data used for interpolation <code>success</code> Boolean Whether send succeeded <code>errorMessage</code> String (optional) Error details if send failed <code>messageId</code> String (optional) SMTP message ID if send succeeded <code>sentByUserId</code> String (optional) User who triggered test send <code>createdAt</code> DateTime Test send timestamp <p>Relations: - <code>template</code> \u2014 EmailTemplate (N:1) - <code>sentBy</code> \u2014 User (N:1)</p> <p>Indexes: - Index on <code>templateId</code> for template-specific test history - Index on <code>createdAt</code> for chronological queries</p>"},{"location":"v2/features/email-templates/template-system/#template-categories","title":"Template Categories","text":""},{"location":"v2/features/email-templates/template-system/#influence-category","title":"INFLUENCE Category","text":"<p>Purpose: Advocacy campaign emails sent to representatives or response notifications to participants.</p> <p>System Templates:</p> Key Name Description <code>campaign-email</code> Campaign Email to Representative Main advocacy email template sent on behalf of participants <code>response-verification</code> Response Verification Email Email asking participants to verify their response submission <code>response-approved</code> Response Approval Notification Email notifying participant their response is published on wall <code>response-rejected</code> Response Rejection Notification Email notifying participant their response was rejected (with reason) <p>Common Variables: - <code>USER_NAME</code> \u2014 Participant's full name - <code>USER_EMAIL</code> \u2014 Participant's email address - <code>CAMPAIGN_TITLE</code> \u2014 Campaign name - <code>CAMPAIGN_SLUG</code> \u2014 URL-safe campaign identifier - <code>REPRESENTATIVE_NAME</code> \u2014 Representative's full name - <code>REPRESENTATIVE_EMAIL</code> \u2014 Representative's email address - <code>REPRESENTATIVE_TITLE</code> \u2014 Representative's title (e.g., \"MP for...\") - <code>CUSTOM_MESSAGE</code> \u2014 Participant's custom message to representative - <code>RESPONSE_TEXT</code> \u2014 Participant's response wall submission - <code>VERIFICATION_LINK</code> \u2014 Unique verification URL - <code>ADMIN_NOTES</code> \u2014 Moderator's rejection reason</p>"},{"location":"v2/features/email-templates/template-system/#map-category","title":"MAP Category","text":"<p>Purpose: Location-based emails for volunteer shifts, canvassing sessions, and shift management.</p> <p>System Templates:</p> Key Name Description <code>shift-signup-confirmation</code> Shift Signup Confirmation Email confirming volunteer's shift registration <code>shift-reminder</code> Shift Reminder Email sent 24 hours before shift starts <code>shift-cancellation</code> Shift Cancellation Notice Email notifying volunteer of shift cancellation <code>canvass-session-summary</code> Canvass Session Summary End-of-session report with visit statistics <p>Common Variables: - <code>USER_NAME</code> \u2014 Volunteer's full name - <code>USER_EMAIL</code> \u2014 Volunteer's email address - <code>USER_PHONE</code> \u2014 Volunteer's phone number (optional) - <code>SHIFT_TITLE</code> \u2014 Shift name - <code>SHIFT_DATE</code> \u2014 Shift date (formatted) - <code>SHIFT_TIME</code> \u2014 Shift time range (e.g., \"10:00 AM - 2:00 PM\") - <code>SHIFT_LOCATION</code> \u2014 Shift meeting location - <code>CUT_NAME</code> \u2014 Canvass area name - <code>VISIT_COUNT</code> \u2014 Number of doors knocked - <code>CONTACT_COUNT</code> \u2014 Number of successful contacts - <code>SUPPORT_COUNT</code> \u2014 Number of supporters identified - <code>CANCELLATION_REASON</code> \u2014 Why shift was cancelled</p>"},{"location":"v2/features/email-templates/template-system/#system-category","title":"SYSTEM Category","text":"<p>Purpose: Core platform emails for user management, authentication, and system notifications.</p> <p>System Templates:</p> Key Name Description <code>user-welcome</code> Welcome Email Email sent to new user registrations <code>password-reset</code> Password Reset Email Email with password reset link <code>email-verification</code> Email Verification Email address verification for new accounts <code>account-locked</code> Account Locked Notice Security notification for locked accounts <p>Common Variables: - <code>USER_NAME</code> \u2014 User's full name - <code>USER_EMAIL</code> \u2014 User's email address - <code>VERIFICATION_LINK</code> \u2014 Unique verification URL (expires in 24h) - <code>RESET_LINK</code> \u2014 Unique password reset URL (expires in 1h) - <code>SUPPORT_EMAIL</code> \u2014 Platform support email address - <code>SITE_NAME</code> \u2014 Platform name (from SiteSettings) - <code>SITE_URL</code> \u2014 Platform base URL - <code>LOGIN_URL</code> \u2014 Direct link to login page - <code>LOCKOUT_REASON</code> \u2014 Why account was locked</p>"},{"location":"v2/features/email-templates/template-system/#variable-interpolation","title":"Variable Interpolation","text":"<p>The template system uses Handlebars for powerful variable interpolation with support for basic variables, conditional blocks, loops, and helpers.</p>"},{"location":"v2/features/email-templates/template-system/#basic-variables","title":"Basic Variables","text":"<p>Syntax: <code>{{VARIABLE_NAME}}</code></p> <p>Example Template: <pre><code><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</code></pre></p> <p>Sample Data: <pre><code>{\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</code></pre></p> <p>Rendered Output: <pre><code><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</code></pre></p>"},{"location":"v2/features/email-templates/template-system/#conditional-blocks","title":"Conditional Blocks","text":"<p>Syntax: <code>{{#if CONDITION}} ... {{else}} ... {{/if}}</code></p> <p>Example Template: <pre><code><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</code></pre></p> <p>Sample Data: <pre><code>{\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</code></pre></p> <p>Rendered Output: <pre><code><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</code></pre></p> <p>Truthy/Falsy Values: - <code>true</code>, non-empty strings, non-zero numbers \u2192 truthy - <code>false</code>, <code>null</code>, <code>undefined</code>, <code>0</code>, <code>\"\"</code> \u2192 falsy</p>"},{"location":"v2/features/email-templates/template-system/#loops-each-blocks","title":"Loops (Each Blocks)","text":"<p>Syntax: <code>{{#each ARRAY}} ... {{/each}}</code></p> <p>Example Template: <pre><code><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</code></pre></p> <p>Sample Data: <pre><code>{\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</code></pre></p> <p>Rendered Output: <pre><code><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</code></pre></p> <p>Loop Variables: - <code>{{@index}}</code> \u2014 0-based index - <code>{{@first}}</code> \u2014 true if first item - <code>{{@last}}</code> \u2014 true if last item</p>"},{"location":"v2/features/email-templates/template-system/#raw-html-unescaped","title":"Raw HTML (Unescaped)","text":"<p>Syntax: <code>{{{VARIABLE_NAME}}}</code> (triple braces)</p> <p>By default, Handlebars escapes HTML to prevent XSS attacks. Use triple braces for trusted HTML content.</p> <p>Example Template: <pre><code><p>Dear {{USER_NAME}},</p>\n\n<div class=\"message-content\">\n {{{FORMATTED_MESSAGE}}}\n</div>\n</code></pre></p> <p>Sample Data: <pre><code>{\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</code></pre></p> <p>Rendered Output: <pre><code><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</code></pre></p> <p>Security Warning: Only use <code>{{{...}}}</code> for content generated by the application, never for user-submitted content without sanitization.</p>"},{"location":"v2/features/email-templates/template-system/#admin-workflow","title":"Admin Workflow","text":""},{"location":"v2/features/email-templates/template-system/#viewing-templates","title":"Viewing Templates","text":"<ol> <li>Navigate to Email Templates Page</li> <li>Admin sidebar \u2192 Email Templates</li> <li> <p>Shows table with all templates grouped by category</p> </li> <li> <p>Filter and Search</p> </li> <li>Filter by category (INFLUENCE, MAP, SYSTEM)</li> <li>Search by template name or key</li> <li> <p>Toggle \"Show Inactive\" to view disabled templates</p> </li> <li> <p>Template Details</p> </li> <li>Click template row to view details modal</li> <li>See subject line, category, active status, system flag</li> <li>View variable list with required/optional labels</li> <li>Access version history tab</li> <li>Access test send tab</li> </ol>"},{"location":"v2/features/email-templates/template-system/#creating-template","title":"Creating Template","text":"<ol> <li>Click \"New Template\" Button</li> <li> <p>Opens template creation modal</p> </li> <li> <p>Enter Template Metadata</p> </li> <li>Key \u2014 Programmatic identifier (lowercase-with-dashes)</li> <li>Name \u2014 Display name for admin GUI</li> <li>Description \u2014 Template purpose and usage notes</li> <li>Category \u2014 Select INFLUENCE, MAP, or SYSTEM</li> <li> <p>System Flag \u2014 Check if template is critical (prevents deletion)</p> </li> <li> <p>Define Variables</p> </li> <li>Click \"Add Variable\" in variables section</li> <li>Enter variable key (UPPERCASE_WITH_UNDERSCORES)</li> <li>Enter label and description</li> <li>Toggle required/conditional flags</li> <li>Provide sample value for testing</li> <li> <p>Set sort order (drag to reorder)</p> </li> <li> <p>Write Template Content</p> </li> <li>Subject Line \u2014 Enter subject with optional {{VARIABLES}}</li> <li>HTML Content \u2014 Write HTML body with {{VARIABLES}}</li> <li> <p>Text Content \u2014 Write plain text fallback</p> </li> <li> <p>Save Template</p> </li> <li>Click \"Save\" to create template</li> <li>Creates version 1 automatically</li> <li>Template is active by default</li> </ol>"},{"location":"v2/features/email-templates/template-system/#editing-template","title":"Editing Template","text":"<ol> <li>Open Template</li> <li>Email Templates page \u2192 click template</li> <li> <p>Opens detail modal</p> </li> <li> <p>Click \"Edit\" Button</p> </li> <li>Opens EmailTemplateEditorPage in new tab</li> <li> <p>Shows split-pane editor (HTML + Text)</p> </li> <li> <p>Modify Content</p> </li> <li>Edit subject line, HTML, or text content</li> <li>Use variable insertion buttons to add {{VARIABLES}}</li> <li> <p>Preview rendered output with sample data</p> </li> <li> <p>Add Change Notes</p> </li> <li>Enter description of changes in \"Change Notes\" field</li> <li> <p>Used for version history audit trail</p> </li> <li> <p>Save Changes</p> </li> <li>Click \"Save\" button</li> <li>Creates new version automatically</li> <li>Redirects to Email Templates page</li> </ol>"},{"location":"v2/features/email-templates/template-system/#testing-template","title":"Testing Template","text":"<ol> <li>Open Template Detail Modal</li> <li> <p>Click template from list</p> </li> <li> <p>Navigate to \"Test Send\" Tab</p> </li> <li> <p>Enter Test Parameters</p> </li> <li>Recipient Email \u2014 Your email address for test</li> <li>Sample Data \u2014 JSON object with variable values</li> <li> <p>Pre-filled with variable sample values</p> </li> <li> <p>Click \"Send Test Email\"</p> </li> <li>Template is rendered with sample data</li> <li>Email sent via SMTP (or MailHog in test mode)</li> <li> <p>Success/failure notification displayed</p> </li> <li> <p>Check Test Log</p> </li> <li>View test send history in \"Test Logs\" tab</li> <li>See timestamp, recipient, success status, error messages</li> <li>Review sample data used for each test</li> </ol>"},{"location":"v2/features/email-templates/template-system/#activatingdeactivating-template","title":"Activating/Deactivating Template","text":"<ol> <li> <p>Open Template Detail Modal</p> </li> <li> <p>Toggle \"Active\" Switch</p> </li> <li>When inactive, template won't send emails</li> <li> <p>Useful for disabling seasonal templates or broken templates</p> </li> <li> <p>Confirm Action</p> </li> <li>System templates require additional confirmation</li> <li>Deactivating system template may break critical platform functions</li> </ol>"},{"location":"v2/features/email-templates/template-system/#developer-workflow-adding-new-template","title":"Developer Workflow (Adding New Template)","text":""},{"location":"v2/features/email-templates/template-system/#step-1-define-template-key","title":"Step 1: Define Template Key","text":"<p>Choose a descriptive, unique key using lowercase with dashes:</p> <p>Good Keys: - <code>shift-signup-confirmation</code> - <code>canvass-session-summary</code> - <code>response-verification</code></p> <p>Bad Keys: - <code>template1</code> (not descriptive) - <code>ShiftSignup</code> (wrong case) - <code>shift_signup</code> (use dashes, not underscores)</p>"},{"location":"v2/features/email-templates/template-system/#step-2-create-template-via-seed-script","title":"Step 2: Create Template via Seed Script","text":"<p>Add to <code>api/prisma/seed.ts</code>:</p> <pre><code>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</code></pre> <p>Run Seed: <pre><code>docker compose exec api npx prisma db seed\n</code></pre></p>"},{"location":"v2/features/email-templates/template-system/#step-3-define-variables","title":"Step 3: Define Variables","text":"<p>Add variables in same seed script:</p> <pre><code>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</code></pre>"},{"location":"v2/features/email-templates/template-system/#step-4-use-in-code","title":"Step 4: Use in Code","text":"<p>Send email from template:</p> <pre><code>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</code></pre>"},{"location":"v2/features/email-templates/template-system/#step-5-document-template","title":"Step 5: Document Template","text":"<p>Add to API documentation:</p> <p>Create entry in <code>mkdocs/docs/v2/api/email-templates.md</code>:</p> <pre><code>## 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</code></pre>"},{"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":"<p>Basic Usage:</p> <pre><code>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</code></pre> <p>With Conditional Variables:</p> <pre><code>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</code></pre> <p>With Loops (Array Variables):</p> <pre><code>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</code></pre>"},{"location":"v2/features/email-templates/template-system/#template-service-implementation","title":"Template Service Implementation","text":"<p>Core sendFromTemplate Method:</p> <pre><code>// 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</code></pre>"},{"location":"v2/features/email-templates/template-system/#handlebars-helper-registration","title":"Handlebars Helper Registration","text":"<p>Register custom helpers for common formatting:</p> <pre><code>// 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</code></pre> <p>Usage in Templates:</p> <pre><code><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</code></pre>"},{"location":"v2/features/email-templates/template-system/#error-handling","title":"Error Handling","text":"<p>Custom Error Classes:</p> <pre><code>// 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</code></pre> <p>Service Error Handling:</p> <pre><code>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</code></pre>"},{"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":"<p>Symptoms: - <code>EmailTemplateNotFoundError: Template not found or inactive: shift-reminder</code> - Email not sent, exception thrown</p> <p>Causes: 1. Template key typo (case-sensitive) 2. Template is inactive (<code>isActive = false</code>) 3. Template doesn't exist in database</p> <p>Solutions:</p> <p>Check template exists: <pre><code>SELECT * FROM email_templates WHERE key = 'shift-reminder';\n</code></pre></p> <p>Check active status: <pre><code>SELECT key, is_active FROM email_templates WHERE key = 'shift-reminder';\n</code></pre></p> <p>Activate template: <pre><code>UPDATE email_templates SET is_active = true WHERE key = 'shift-reminder';\n</code></pre></p> <p>Create template via admin GUI or seed script (see Developer Workflow above)</p>"},{"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":"<p>Symptoms: - Rendered email shows <code>{{USER_NAME}}</code> instead of \"John Doe\" - Variables appear as raw text in subject or body</p> <p>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</p> <p>Solutions:</p> <p>Check variable key matches exactly: <pre><code>// 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</code></pre></p> <p>Console log data object: <pre><code>console.log('Template data:', JSON.stringify(options.data, null, 2));\n</code></pre></p> <p>Test Handlebars compilation: <pre><code>const Handlebars = require('handlebars');\nconst template = Handlebars.compile('Hello {{USER_NAME}}!');\nconsole.log(template({ USER_NAME: 'Test' })); // Should output: \"Hello Test!\"\n</code></pre></p> <p>Verify template content: <pre><code>SELECT subject_line, html_content FROM email_templates WHERE key = 'shift-reminder';\n</code></pre></p>"},{"location":"v2/features/email-templates/template-system/#problem-missing-required-variable-error","title":"Problem: Missing required variable error","text":"<p>Symptoms: - <code>MissingRequiredVariableError: Missing required variables for template shift-reminder: SHIFT_DATE, SHIFT_TIME</code> - Email not sent, exception thrown</p> <p>Causes: 1. Required variable not provided in data object 2. Variable value is <code>null</code> or <code>undefined</code></p> <p>Solutions:</p> <p>Check EmailTemplateVariable.isRequired: <pre><code>SELECT key, label, is_required\nFROM email_template_variables\nWHERE template_id = (SELECT id FROM email_templates WHERE key = 'shift-reminder');\n</code></pre></p> <p>Provide all required variables: <pre><code>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</code></pre></p> <p>Temporary fix (set isRequired = false): <pre><code>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</code></pre></p> <p>Long-term fix: Update code to always provide required variables</p>"},{"location":"v2/features/email-templates/template-system/#problem-email-sent-to-wrong-recipient","title":"Problem: Email sent to wrong recipient","text":"<p>Symptoms: - Test email sent to production recipient - User receives email meant for another user</p> <p>Causes: 1. Wrong <code>recipientEmail</code> parameter 2. Email test mode disabled (<code>EMAIL_TEST_MODE=false</code>) 3. Variable interpolation pulled wrong user data</p> <p>Solutions:</p> <p>Enable test mode in development: <pre><code># .env\nEMAIL_TEST_MODE=true\n</code></pre></p> <p>Check recipient email: <pre><code>console.log('Sending email to:', options.recipientEmail);\n</code></pre></p> <p>Use MailHog in dev: - All emails captured at http://localhost:8025 - Never sent to real recipients</p> <p>Verify user data query: <pre><code>const volunteer = await prisma.user.findUnique({ where: { id: volunteerId } });\nconsole.log('Volunteer email:', volunteer.email);\n</code></pre></p>"},{"location":"v2/features/email-templates/template-system/#problem-html-rendering-broken-in-email-client","title":"Problem: HTML rendering broken in email client","text":"<p>Symptoms: - Email looks correct in preview but broken in Gmail/Outlook - Images not loading - Styles not applied</p> <p>Causes: 1. Email client doesn't support modern CSS 2. External images blocked by email client 3. Invalid HTML structure</p> <p>Solutions:</p> <p>Use inline styles (not CSS classes): <pre><code><!-- \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</code></pre></p> <p>Use tables for layout (not flexbox/grid): <pre><code><!-- \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</code></pre></p> <p>Embed images as data URIs or use absolute URLs: <pre><code><!-- \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</code></pre></p> <p>Test in multiple email clients: - Use Litmus or Email on Acid - Test in Gmail, Outlook, Apple Mail, Yahoo Mail</p>"},{"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":"<p>Current Implementation: - Templates fetched from database on every send - Includes variable definitions in same query - No caching layer</p> <p>Performance Impact: - Single database query per email send (~10ms) - Acceptable for low-volume sends (< 100/min) - May bottleneck for high-volume campaigns (> 1000/min)</p> <p>Optimization Options:</p> <p>1. In-Memory Caching: <pre><code>// 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</code></pre></p> <p>2. Redis Caching: <pre><code>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</code></pre></p> <p>3. Cache Invalidation: <pre><code>// When template is updated\nawait redis.del(`email-template:${template.key}`);\nthis.templateCache.delete(template.key);\n</code></pre></p>"},{"location":"v2/features/email-templates/template-system/#handlebars-compilation","title":"Handlebars Compilation","text":"<p>Performance: - Handlebars compilation is fast (~1ms per template) - No significant bottleneck for typical templates</p> <p>Large Templates: - Templates > 100KB may take 5-10ms to compile - Solution: Pre-compile templates and cache compiled functions</p> <p>Pre-Compilation: <pre><code>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</code></pre></p>"},{"location":"v2/features/email-templates/template-system/#bulk-email-sending","title":"Bulk Email Sending","text":"<p>Problem: Sending 1000+ emails sequentially is slow (1-2 seconds per email)</p> <p>Solution: Use BullMQ job queue for async batch processing</p> <p>Queue Implementation: <pre><code>// 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</code></pre></p> <p>Usage: <pre><code>// Queue 1000 emails\nfor (const volunteer of volunteers) {\n await queueEmail('shift-reminder', {\n recipientEmail: volunteer.email,\n data: { ... },\n });\n}\n</code></pre></p>"},{"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":"<p>Risk: Admin-authored templates may contain malicious JavaScript</p> <p>Handlebars Auto-Escaping: - By default, <code>{{VAR}}</code> escapes HTML entities - <code>&</code> \u2192 <code>&amp;</code>, <code><</code> \u2192 <code>&lt;</code>, <code>></code> \u2192 <code>&gt;</code></p> <p>Raw HTML (Unescaped): - <code>{{{VAR}}}</code> (triple braces) renders raw HTML - Use ONLY for trusted, application-generated content - NEVER use for user-submitted content without sanitization</p> <p>Example: <pre><code><!-- 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</code></pre></p> <p>Sanitization: <pre><code>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</code></pre></p>"},{"location":"v2/features/email-templates/template-system/#email-address-validation","title":"Email Address Validation","text":"<p>Risk: Invalid email addresses cause SMTP errors or bounce emails</p> <p>Validation Before Sending: <pre><code>import validator from 'validator';\n\nif (!validator.isEmail(options.recipientEmail)) {\n throw new Error('Invalid recipient email address');\n}\n</code></pre></p> <p>Bounce Handling: - Monitor bounce notifications from SMTP provider - Mark bounced emails in database - Disable sending to repeatedly bounced addresses</p>"},{"location":"v2/features/email-templates/template-system/#rate-limiting-template-test-sends","title":"Rate Limiting Template Test Sends","text":"<p>Risk: Admin spamming test sends</p> <p>Rate Limit Implementation: <pre><code>// 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</code></pre></p>"},{"location":"v2/features/email-templates/template-system/#template-injection-attacks","title":"Template Injection Attacks","text":"<p>Risk: Admin injects malicious Handlebars helpers or expressions</p> <p>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()</p> <p>Safe: <pre><code>{{USER_NAME}}\n{{#if HAS_PHONE}}{{USER_PHONE}}{{/if}}\n{{#each ITEMS}}{{name}}{{/each}}\n</code></pre></p> <p>Already Prevented by Handlebars: <pre><code><!-- These do NOT execute, render as literal text -->\n{{require('fs').readFileSync('/etc/passwd')}}\n{{process.env.DATABASE_URL}}\n</code></pre></p> <p>Best Practice: Still review templates before activating</p>"},{"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":"<ul> <li>EmailTemplatesPage.tsx \u2014 Email templates list page with CRUD table</li> <li>EmailTemplateEditorPage.tsx \u2014 Split-pane editor with preview</li> <li>Components:</li> <li>Variable insertion panel</li> <li>Live preview renderer</li> <li>Test send form</li> <li>Version comparison modal</li> </ul>"},{"location":"v2/features/email-templates/template-system/#backend-documentation","title":"Backend Documentation","text":"<ul> <li>Email Templates Module \u2014 API routes and schemas</li> <li><code>GET /api/email-templates</code> \u2014 List templates (with filters)</li> <li><code>POST /api/email-templates</code> \u2014 Create template</li> <li><code>PUT /api/email-templates/:id</code> \u2014 Update template</li> <li><code>DELETE /api/email-templates/:id</code> \u2014 Delete template (system templates protected)</li> <li><code>POST /api/email-templates/:id/test</code> \u2014 Send test email</li> <li><code>GET /api/email-templates/:id/versions</code> \u2014 Version history</li> <li><code>POST /api/email-templates/:id/rollback/:versionNumber</code> \u2014 Restore version</li> <li>Email Service \u2014 Core email sending logic</li> <li><code>sendFromTemplate()</code> \u2014 Load, validate, interpolate, send</li> <li><code>send()</code> \u2014 Low-level Nodemailer wrapper</li> <li>Handlebars helper registration</li> </ul>"},{"location":"v2/features/email-templates/template-system/#database-documentation","title":"Database Documentation","text":"<ul> <li>Email Templates Models \u2014 Schema definitions</li> <li>EmailTemplate model</li> <li>EmailTemplateVariable model</li> <li>EmailTemplateVersion model</li> <li>EmailTemplateTestLog model</li> <li>Indexes and constraints</li> </ul>"},{"location":"v2/features/email-templates/template-system/#feature-documentation","title":"Feature Documentation","text":"<ul> <li>editor.md \u2014 Email template editor interface</li> <li>variables.md \u2014 Template variable system</li> <li>versioning.md \u2014 Template version history</li> </ul>"},{"location":"v2/features/email-templates/template-system/#configuration","title":"Configuration","text":"<ul> <li>Environment Variables \u2014 Email-related env vars</li> <li><code>EMAIL_TEST_MODE</code> \u2014 Enable MailHog capture</li> <li>SMTP settings (host, port, user, password)</li> <li>Site Settings \u2014 Site-wide email settings</li> <li>Default from name/email</li> <li>SMTP override settings</li> </ul>"},{"location":"v2/features/email-templates/variables/","title":"Template Variables System","text":""},{"location":"v2/features/email-templates/variables/#overview","title":"Overview","text":"<p>The Template Variables System defines reusable placeholders for email templates, enabling dynamic content interpolation with validation, documentation, and sample values. Variables are defined per template and provide metadata for variable insertion UI, validation logic, and testing workflows.</p> <p>Key Features:</p> <ul> <li>Per-Template Variables \u2014 Each template has its own variable definitions</li> <li>Required vs Optional \u2014 Enforce required variables at runtime</li> <li>Conditional Variables \u2014 Boolean/truthy flags for <code>{{#if}}</code> blocks</li> <li>Sample Values \u2014 Example data for testing and preview</li> <li>Sort Order \u2014 Control display order in editor UI</li> <li>Documentation \u2014 Labels and descriptions for self-documenting templates</li> <li>Validation \u2014 Runtime checks prevent missing variable errors</li> <li>Reusability \u2014 Common variables (USER_NAME, USER_EMAIL) across templates</li> </ul> <p>Benefits:</p> <ul> <li>Type Safety \u2014 Know what data is expected before sending</li> <li>Self-Documentation \u2014 Variables describe their purpose</li> <li>Better Testing \u2014 Sample values pre-fill test send forms</li> <li>Consistency \u2014 Standardized variable naming across templates</li> <li>Error Prevention \u2014 Catch missing variables before SMTP send</li> </ul>"},{"location":"v2/features/email-templates/variables/#architecture","title":"Architecture","text":"<pre><code>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</code></pre> <p>Component Responsibilities:</p> <ul> <li>EmailTemplateVariable \u2014 Database model storing variable metadata</li> <li>Variable Insertion Panel \u2014 Editor UI for inserting <code>{{VARIABLES}}</code></li> <li>Sample Data Form \u2014 Preview/test form pre-filled with sample values</li> <li>Validation Service \u2014 Runtime checks before template interpolation</li> <li>Handlebars Engine \u2014 Replaces <code>{{VAR}}</code> with data values</li> </ul>"},{"location":"v2/features/email-templates/variables/#database-model","title":"Database Model","text":""},{"location":"v2/features/email-templates/variables/#emailtemplatevariable-schema","title":"EmailTemplateVariable Schema","text":"<p>Table: <code>email_template_variables</code></p> Field Type Description <code>id</code> String (CUID) Primary key <code>templateId</code> String Foreign key to EmailTemplate <code>key</code> String Variable name (UPPERCASE_WITH_UNDERSCORES) <code>label</code> String Display label for UI (\"User's Full Name\") <code>description</code> String (optional) Variable purpose and usage notes <code>isRequired</code> Boolean If true, must be provided in data object <code>isConditional</code> Boolean If true, used in <code>{{#if}}</code> blocks (truthy/falsy) <code>sampleValue</code> String (optional) Example value for testing/preview <code>sortOrder</code> Int Display order in UI (1, 2, 3...) <code>createdAt</code> DateTime Creation timestamp <p>Relations: - <code>template</code> \u2014 EmailTemplate (N:1)</p> <p>Constraints: - Unique index on <code>(templateId, key)</code> \u2014 prevents duplicate variables per template - Index on <code>sortOrder</code> for ordered queries</p> <p>Prisma Schema: <pre><code>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</code></pre></p>"},{"location":"v2/features/email-templates/variables/#variable-types","title":"Variable Types","text":""},{"location":"v2/features/email-templates/variables/#required-variables","title":"Required Variables","text":"<p>Purpose: Must be provided in data object for template to send.</p> <p>Behavior: - Validation checks for presence before interpolation - Throws <code>MissingRequiredVariableError</code> if missing - Marked with red \"Required\" badge in editor UI</p> <p>When to Use: - Variables that appear in ALL template renders - Variables without fallback values - Critical data (e.g., recipient name, event date)</p> <p>Example: <pre><code>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</code></pre></p> <p>Template Usage: <pre><code><p>Dear {{USER_NAME}},</p>\n<!-- USER_NAME is required, error if missing -->\n</code></pre></p>"},{"location":"v2/features/email-templates/variables/#optional-variables","title":"Optional Variables","text":"<p>Purpose: May be omitted from data object (defaults to empty string).</p> <p>Behavior: - No validation error if missing - Handlebars renders as empty string if undefined - Useful for conditional content or nice-to-have data</p> <p>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</p> <p>Example: <pre><code>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</code></pre></p> <p>Template Usage: <pre><code>{{#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</code></pre></p>"},{"location":"v2/features/email-templates/variables/#conditional-variables","title":"Conditional Variables","text":"<p>Purpose: Boolean or truthy/falsy values for <code>{{#if}}</code> blocks.</p> <p>Behavior: - <code>isConditional: true</code> marks variable as boolean-like - Editor UI shows blue \"Conditional\" badge - Used in <code>{{#if VAR}}...{{/if}}</code> blocks - Can also be required or optional</p> <p>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)</p> <p>Example: <pre><code>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</code></pre></p> <p>Template Usage: <pre><code>{{#if HAS_PHONE}}\n<p>Contact: {{USER_PHONE}}</p>\n{{/if}}\n</code></pre></p> <p>Truthy Values: - <code>true</code>, <code>'true'</code>, <code>1</code>, non-empty strings, non-empty arrays</p> <p>Falsy Values: - <code>false</code>, <code>'false'</code>, <code>0</code>, <code>''</code>, <code>null</code>, <code>undefined</code>, <code>[]</code></p>"},{"location":"v2/features/email-templates/variables/#array-variables-loops","title":"Array Variables (Loops)","text":"<p>Purpose: Collections for <code>{{#each}}</code> blocks.</p> <p>Behavior: - Not explicitly marked (same as other variables) - Sample value should be JSON array string - Used in <code>{{#each VAR}}...{{/each}}</code> loops</p> <p>When to Use: - Lists of representatives, shift assignments, visit outcomes - Dynamic content length (1-N items)</p> <p>Example: <pre><code>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</code></pre></p> <p>Template Usage: <pre><code><ul>\n{{#each REPRESENTATIVES}}\n <li>\n <strong>{{name}}</strong> ({{title}})<br>\n Email: {{email}}\n </li>\n{{/each}}\n</ul>\n</code></pre></p> <p>Data Object: <pre><code>{\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</code></pre></p>"},{"location":"v2/features/email-templates/variables/#admin-workflow","title":"Admin Workflow","text":""},{"location":"v2/features/email-templates/variables/#viewing-variables","title":"Viewing Variables","text":"<p>From EmailTemplatesPage:</p> <ol> <li>Click Template Row</li> <li> <p>Opens template detail modal</p> </li> <li> <p>Navigate to \"Variables\" Tab</p> </li> <li>Shows table of all variables</li> <li> <p>Columns: Key, Label, Required, Conditional, Sample Value, Sort Order</p> </li> <li> <p>Variable Details</p> </li> <li>Click variable row for description</li> <li>See where variable is used in template content</li> <li>View sample value</li> </ol> <p>From EmailTemplateEditorPage:</p> <ol> <li>Open Template Editor</li> <li> <p>Variables shown in right sidebar</p> </li> <li> <p>Variable Insertion Panel</p> </li> <li>Variables listed with labels, badges, descriptions</li> <li>Sorted by sortOrder ascending</li> <li>Click \"Insert to HTML/Text\" buttons</li> </ol>"},{"location":"v2/features/email-templates/variables/#adding-variable","title":"Adding Variable","text":"<p>Step 1: Open Variables Tab - EmailTemplatesPage \u2192 click template \u2192 \"Variables\" tab</p> <p>Step 2: Click \"Add Variable\" Button - Opens variable creation modal</p> <p>Step 3: Enter Variable Metadata</p> <p>Key (required): - Uppercase with underscores (e.g., <code>USER_NAME</code>) - Must be unique within template - Used in template as <code>{{KEY}}</code></p> <p>Label (required): - Display name for UI (e.g., \"User's Full Name\") - Human-readable description</p> <p>Description (optional): - Detailed explanation of variable purpose - Usage notes (e.g., \"Must be in YYYY-MM-DD format\")</p> <p>Is Required: - Toggle on if variable must always be provided - Validation will fail if missing</p> <p>Is Conditional: - Toggle on if variable is used in <code>{{#if}}</code> blocks - UI shows blue \"Conditional\" badge</p> <p>Sample Value (optional): - Example value for testing/preview - Pre-fills test send form - Shows expected data format</p> <p>Sort Order: - Numeric order for UI display - Lower numbers appear first (1, 2, 3...) - Auto-assigned if not specified</p> <p>Step 4: Save Variable - Click \"Save\" button - Variable added to template - Available in editor insertion panel</p>"},{"location":"v2/features/email-templates/variables/#editing-variable","title":"Editing Variable","text":"<p>Step 1: Open Variables Tab - EmailTemplatesPage \u2192 click template \u2192 \"Variables\" tab</p> <p>Step 2: Click Variable Row - Opens variable edit modal - Shows current values</p> <p>Step 3: Modify Fields - Change label, description, flags, sample value - Cannot change key (would break existing templates)</p> <p>Step 4: Save Changes - Click \"Save\" button - Variable updated in database</p> <p>Note: Changing variable key requires creating new variable and updating template content manually.</p>"},{"location":"v2/features/email-templates/variables/#deleting-variable","title":"Deleting Variable","text":"<p>Step 1: Check Template Usage - Search template content for <code>{{VAR_KEY}}</code> - Ensure variable is not used in subject/HTML/text</p> <p>Step 2: Click Delete Button - Variables tab \u2192 click variable row \u2192 \"Delete\" button</p> <p>Step 3: Confirm Deletion - Warning modal: \"Are you sure? This cannot be undone.\" - Click \"Confirm Delete\"</p> <p>Step 4: Verify Template Still Valid - Open template editor - Check preview renders without errors - Send test email</p> <p>Warning: Deleting a variable that's still used in template content will cause rendering errors (<code>{{VAR}}</code> will appear as literal text).</p>"},{"location":"v2/features/email-templates/variables/#reordering-variables","title":"Reordering Variables","text":"<p>Step 1: Open Variables Tab - EmailTemplatesPage \u2192 click template \u2192 \"Variables\" tab</p> <p>Step 2: Drag to Reorder - Drag variable rows up/down - Drop to new position</p> <p>Step 3: Save Sort Order - Click \"Save Order\" button - Updates <code>sortOrder</code> field for all variables</p> <p>Alternative: Manual Sort Order - Edit variable \u2192 change sortOrder number - Variables re-sort automatically</p>"},{"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":"<p>Seed Script Example:</p> <pre><code>// 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</code></pre>"},{"location":"v2/features/email-templates/variables/#loading-variables-in-code","title":"Loading Variables in Code","text":"<p>With Template: <pre><code>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</code></pre></p> <p>Ordered by Sort: <pre><code>const template = await prisma.emailTemplate.findUnique({\n where: { key: 'shift-signup-confirmation' },\n include: {\n variables: {\n orderBy: { sortOrder: 'asc' },\n },\n },\n});\n</code></pre></p> <p>Required Variables Only: <pre><code>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</code></pre></p>"},{"location":"v2/features/email-templates/variables/#validating-variables","title":"Validating Variables","text":"<p>Validation Function:</p> <pre><code>// 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</code></pre> <p>Usage: <pre><code>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</code></pre></p>"},{"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":"<p>Endpoint: <code>POST /api/email-templates/:id/variables</code></p> <p>Request Body: <pre><code>{\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</code></pre></p> <p>Route Implementation: <pre><code>// 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</code></pre></p>"},{"location":"v2/features/email-templates/variables/#auto-generating-sample-data","title":"Auto-Generating Sample Data","text":"<p>Load Sample Data from Variables:</p> <pre><code>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</code></pre> <p>Usage in Editor: <pre><code>const template = await api.get(`/api/email-templates/${id}`);\nconst sampleData = generateSampleData(template.variables);\n\nsetSampleData(sampleData);\n</code></pre></p>"},{"location":"v2/features/email-templates/variables/#variable-usage-detection","title":"Variable Usage Detection","text":"<p>Find Variables Used in Template Content:</p> <pre><code>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</code></pre> <p>Check for Unused Variables: <pre><code>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</code></pre></p>"},{"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":"<p>Standard Variables:</p> Key Label Required Conditional Description <code>USER_NAME</code> User Name Yes No Participant's full name <code>USER_EMAIL</code> User Email Yes No Participant's email address <code>CAMPAIGN_TITLE</code> Campaign Title Yes No Campaign name <code>CAMPAIGN_SLUG</code> Campaign Slug Yes No URL-safe campaign identifier <code>CAMPAIGN_URL</code> Campaign URL No No Full URL to campaign page <code>REPRESENTATIVE_NAME</code> Representative Name Yes No Representative's full name <code>REPRESENTATIVE_TITLE</code> Representative Title Yes No Representative's title (e.g., \"MP for Downtown\") <code>REPRESENTATIVE_EMAIL</code> Representative Email Yes No Representative's email address <code>CUSTOM_MESSAGE</code> Custom Message Yes No Participant's custom message to representative <code>RESPONSE_TEXT</code> Response Text No No Participant's response wall submission <code>VERIFICATION_LINK</code> Verification Link No No Unique verification URL <code>HAS_CUSTOM_MESSAGE</code> Has Custom Message No Yes Whether participant added custom message <p>Usage Example: <pre><code>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</code></pre></p>"},{"location":"v2/features/email-templates/variables/#map-templates","title":"MAP Templates","text":"<p>Standard Variables:</p> Key Label Required Conditional Description <code>USER_NAME</code> User Name Yes No Volunteer's full name <code>USER_EMAIL</code> User Email Yes No Volunteer's email address <code>USER_PHONE</code> User Phone No No Volunteer's phone number (optional) <code>HAS_PHONE</code> Has Phone No Yes Whether user provided phone number <code>SHIFT_TITLE</code> Shift Title Yes No Shift name <code>SHIFT_DATE</code> Shift Date Yes No Formatted shift date <code>SHIFT_TIME</code> Shift Time Yes No Shift time range (e.g., \"10:00 AM - 2:00 PM\") <code>SHIFT_LOCATION</code> Shift Location Yes No Meeting location for shift <code>CUT_NAME</code> Cut Name No No Canvass area name <code>IS_CUT_ASSIGNED</code> Is Cut Assigned No Yes Whether volunteer is assigned to a cut <code>VISIT_COUNT</code> Visit Count No No Number of doors knocked (session summary) <code>CONTACT_COUNT</code> Contact Count No No Number of successful contacts <code>SUPPORT_COUNT</code> Support Count No No Number of supporters identified <p>Usage Example: <pre><code>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</code></pre></p>"},{"location":"v2/features/email-templates/variables/#system-templates","title":"SYSTEM Templates","text":"<p>Standard Variables:</p> Key Label Required Conditional Description <code>USER_NAME</code> User Name Yes No User's full name <code>USER_EMAIL</code> User Email Yes No User's email address <code>VERIFICATION_LINK</code> Verification Link No No Unique verification URL (expires 24h) <code>RESET_LINK</code> Reset Link No No Unique password reset URL (expires 1h) <code>SUPPORT_EMAIL</code> Support Email Yes No Platform support email address <code>SITE_NAME</code> Site Name Yes No Platform name (from SiteSettings) <code>SITE_URL</code> Site URL Yes No Platform base URL <code>LOGIN_URL</code> Login URL No No Direct link to login page <code>LOCKOUT_REASON</code> Lockout Reason No No Why account was locked (security) <p>Usage Example: <pre><code>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</code></pre></p>"},{"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":"<p>Symptoms: - Variable exists in database but not shown in editor insertion panel - Variable missing from variables list</p> <p>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)</p> <p>Solutions:</p> <p>Check variable exists: <pre><code>SELECT * FROM email_template_variables\nWHERE template_id = 'cuid123' AND key = 'USER_NAME';\n</code></pre></p> <p>Verify template ID: <pre><code>SELECT id, key FROM email_templates WHERE key = 'shift-reminder';\n-- Check ID matches variable.template_id\n</code></pre></p> <p>Refresh editor page: - Hard refresh (Ctrl+Shift+R) - Clear browser cache</p> <p>Check sort order: <pre><code>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</code></pre></p>"},{"location":"v2/features/email-templates/variables/#problem-validation-error-for-optional-variable","title":"Problem: Validation error for optional variable","text":"<p>Symptoms: - <code>MissingRequiredVariableError</code> thrown for variable marked as optional - Email send fails unexpectedly</p> <p>Causes: 1. Variable incorrectly marked as required in database 2. Validation logic bug 3. Template uses variable in required context</p> <p>Solutions:</p> <p>Check isRequired flag: <pre><code>SELECT key, is_required FROM email_template_variables\nWHERE key = 'USER_PHONE' AND template_id = 'cuid123';\n</code></pre></p> <p>Update to optional: <pre><code>UPDATE email_template_variables\nSET is_required = false\nWHERE key = 'USER_PHONE' AND template_id = 'cuid123';\n</code></pre></p> <p>Provide variable anyway: <pre><code>// Temporary fix: always provide optional variables\ndata: {\n USER_PHONE: volunteer.phone || '', // Empty string if missing\n}\n</code></pre></p> <p>Check validation logic: <pre><code>// 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</code></pre></p>"},{"location":"v2/features/email-templates/variables/#problem-sample-value-not-used-in-preview","title":"Problem: Sample value not used in preview","text":"<p>Symptoms: - Preview shows empty values instead of sample values - Test send form doesn't pre-fill</p> <p>Causes: 1. Sample value is null in database 2. Sample data initialization bug 3. Variable added after editor loaded</p> <p>Solutions:</p> <p>Check sample value exists: <pre><code>SELECT key, sample_value FROM email_template_variables\nWHERE template_id = 'cuid123';\n</code></pre></p> <p>Update sample value: <pre><code>UPDATE email_template_variables\nSET sample_value = 'John Doe'\nWHERE key = 'USER_NAME';\n</code></pre></p> <p>Refresh editor: - Close and reopen EmailTemplateEditorPage - Sample data reloads from variables</p> <p>Manual preview data: <pre><code>// Editor UI allows manual editing of sample data\nsetSampleData({\n ...sampleData,\n USER_NAME: 'Test Name',\n});\n</code></pre></p>"},{"location":"v2/features/email-templates/variables/#problem-duplicate-variable-key-error","title":"Problem: Duplicate variable key error","text":"<p>Symptoms: - <code>P2002: Unique constraint failed</code> error when creating variable - Cannot add variable with same key</p> <p>Causes: 1. Variable already exists for this template 2. Attempting to create duplicate</p> <p>Solutions:</p> <p>Check existing variables: <pre><code>SELECT * FROM email_template_variables\nWHERE template_id = 'cuid123' AND key = 'USER_NAME';\n</code></pre></p> <p>Update existing instead: <pre><code>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</code></pre></p> <p>Use different key: <pre><code>// If truly need separate variable\nkey: 'USER_FULL_NAME', // Not USER_NAME\n</code></pre></p>"},{"location":"v2/features/email-templates/variables/#problem-variables-not-alphabetically-sorted","title":"Problem: Variables not alphabetically sorted","text":"<p>Symptoms: - Variables appear in random order in editor - Want alphabetical order instead of custom sort</p> <p>Causes: - Sort order not set alphabetically - Need to update sortOrder values</p> <p>Solutions:</p> <p>Sort alphabetically by key: <pre><code>-- 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</code></pre></p> <p>Sort by label: <pre><code>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</code></pre></p> <p>Manual custom order: - Use admin UI to drag-drop reorder - Saves custom sortOrder values</p>"},{"location":"v2/features/email-templates/variables/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/features/email-templates/variables/#variable-loading","title":"Variable Loading","text":"<p>Current Implementation: - Variables loaded with template via <code>include: { variables: true }</code> - Single database query (JOIN) - Fast (< 10ms for typical templates)</p> <p>Optimization for Many Variables: <pre><code>// 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</code></pre></p>"},{"location":"v2/features/email-templates/variables/#validation-performance","title":"Validation Performance","text":"<p>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)</p> <p>Caching Variables: <pre><code>// 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</code></pre></p>"},{"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":"<p>Use UPPERCASE_WITH_UNDERSCORES: <pre><code>// \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</code></pre></p> <p>Be Descriptive: <pre><code>// \u2713 Good\nSHIFT_START_TIME\nCAMPAIGN_TITLE\nIS_EMAIL_VERIFIED\n\n// \u2717 Bad\nTIME // Too vague\nTITLE // Ambiguous\nVERIFIED // Missing context\n</code></pre></p> <p>Prefix Booleans with IS/HAS: <pre><code>// \u2713 Good\nHAS_PHONE\nIS_VERIFIED\nIS_CUT_ASSIGNED\n\n// \u2717 Bad\nPHONE // Not clearly boolean\nVERIFIED // Ambiguous (boolean or timestamp?)\n</code></pre></p>"},{"location":"v2/features/email-templates/variables/#documentation","title":"Documentation","text":"<p>Always Provide Labels: <pre><code>// \u2713 Good\nlabel: 'User\\'s Full Name',\ndescription: 'Full name of the email recipient',\n\n// \u2717 Bad\nlabel: 'Name', // Too generic\ndescription: '',\n</code></pre></p> <p>Document Expected Format: <pre><code>// \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</code></pre></p>"},{"location":"v2/features/email-templates/variables/#sample-values","title":"Sample Values","text":"<p>Provide Realistic Examples: <pre><code>// \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</code></pre></p> <p>Use JSON for Arrays/Objects: <pre><code>// \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</code></pre></p>"},{"location":"v2/features/email-templates/variables/#required-vs-optional","title":"Required vs Optional","text":"<p>Make Variables Required If: - Used in subject line (always visible) - Critical to email meaning (e.g., event date) - No reasonable default value</p> <p>Make Variables Optional If: - Used in conditional blocks (<code>{{#if}}</code>) - Nice-to-have but not critical - Has fallback text in template</p>"},{"location":"v2/features/email-templates/variables/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/features/email-templates/variables/#frontend-documentation","title":"Frontend Documentation","text":"<ul> <li>EmailTemplateEditorPage.tsx \u2014 Variable insertion panel</li> <li>EmailTemplatesPage.tsx \u2014 Variables tab</li> </ul>"},{"location":"v2/features/email-templates/variables/#backend-documentation","title":"Backend Documentation","text":"<ul> <li>Email Templates Module \u2014 Variable CRUD API</li> <li><code>GET /api/email-templates/:id/variables</code> \u2014 List variables</li> <li><code>POST /api/email-templates/:id/variables</code> \u2014 Create variable</li> <li><code>PUT /api/email-templates/:id/variables/:varId</code> \u2014 Update variable</li> <li><code>DELETE /api/email-templates/:id/variables/:varId</code> \u2014 Delete variable</li> </ul>"},{"location":"v2/features/email-templates/variables/#database-documentation","title":"Database Documentation","text":"<ul> <li>Email Templates Models \u2014 EmailTemplateVariable schema</li> </ul>"},{"location":"v2/features/email-templates/variables/#feature-documentation","title":"Feature Documentation","text":"<ul> <li>template-system.md \u2014 Email template engine overview</li> <li>editor.md \u2014 Email template editor interface</li> <li>versioning.md \u2014 Template version history</li> </ul>"},{"location":"v2/features/email-templates/versioning/","title":"Template Version History","text":""},{"location":"v2/features/email-templates/versioning/#overview","title":"Overview","text":"<p>The Template Version History system provides comprehensive audit trails for email template changes with automatic version creation, rollback capability, and change tracking. Every template save creates a new version snapshot, preserving the complete history of modifications with metadata about who changed what and why.</p> <p>Key Features:</p> <ul> <li>Automatic Version Creation \u2014 Every save creates a new version (no manual versioning)</li> <li>Auto-Incrementing Version Numbers \u2014 Sequential numbering (1, 2, 3...) per template</li> <li>Complete Snapshots \u2014 Stores subject line, HTML content, and text content</li> <li>Change Notes \u2014 Optional admin-provided descriptions of changes</li> <li>User Attribution \u2014 Tracks who created each version</li> <li>Rollback Capability \u2014 Restore any previous version (non-destructive)</li> <li>Version Comparison \u2014 Visual diff between any two versions</li> <li>Audit Trail \u2014 Full history for compliance and debugging</li> </ul> <p>Benefits:</p> <ul> <li>Accident Recovery \u2014 Undo mistakes by rolling back to previous version</li> <li>Change Tracking \u2014 See what changed and when</li> <li>Compliance \u2014 Audit trail for regulatory requirements</li> <li>Collaboration \u2014 Multiple admins can see each other's changes</li> <li>Experimentation \u2014 Safely test changes knowing you can rollback</li> <li>Documentation \u2014 Change notes explain why changes were made</li> </ul>"},{"location":"v2/features/email-templates/versioning/#architecture","title":"Architecture","text":"<pre><code>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</code></pre> <p>Component Responsibilities:</p> <ul> <li>EmailTemplateVersion \u2014 Version snapshot storage with metadata</li> <li>Version Service \u2014 Auto-increment logic, version creation</li> <li>Rollback Service \u2014 Restore old version as new version (non-destructive)</li> <li>Comparison Service \u2014 Diff generation between versions</li> <li>Audit Log \u2014 User attribution and change notes</li> </ul>"},{"location":"v2/features/email-templates/versioning/#database-model","title":"Database Model","text":""},{"location":"v2/features/email-templates/versioning/#emailtemplateversion-schema","title":"EmailTemplateVersion Schema","text":"<p>Table: <code>email_template_versions</code></p> Field Type Description <code>id</code> String (CUID) Primary key <code>templateId</code> String Foreign key to EmailTemplate <code>versionNumber</code> Int Auto-incremented version (1, 2, 3...) <code>subjectLine</code> String Subject line snapshot <code>htmlContent</code> Text HTML content snapshot <code>textContent</code> Text Plain text content snapshot <code>changeNotes</code> String (optional) Admin-provided change description <code>createdByUserId</code> String (optional) User who created this version <code>createdAt</code> DateTime Version creation timestamp <p>Relations: - <code>template</code> \u2014 EmailTemplate (N:1) - <code>createdBy</code> \u2014 User (N:1)</p> <p>Constraints: - Unique index on <code>(templateId, versionNumber)</code> for version lookup - Auto-increment logic in service layer (finds max + 1) - No ON DELETE CASCADE (preserve versions even if template deleted)</p> <p>Prisma Schema: <pre><code>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</code></pre></p>"},{"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":"<p>When Versions Are Created: - Admin saves template via EmailTemplateEditorPage - API <code>PUT /api/email-templates/:id</code> endpoint called - Version created BEFORE updating template (snapshot current state)</p> <p>Auto-Increment Logic:</p> <pre><code>// 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</code></pre> <p>Save Template with Versioning:</p> <pre><code>// 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</code></pre> <p>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.</p>"},{"location":"v2/features/email-templates/versioning/#version-number-sequence","title":"Version Number Sequence","text":"<p>Sequence Rules: - Starts at 1 for first version - Increments by 1 for each save - Per-template sequence (not global) - No gaps in sequence</p> <p>Example Timeline:</p> Action Version Subject HTML Change Notes Create template 1 \"Welcome!\" <code><p>Hello</p></code> (initial version) Edit subject 2 \"Welcome to Our Platform!\" <code><p>Hello</p></code> \"Made subject more descriptive\" Add content 3 \"Welcome to Our Platform!\" <code><p>Hello {{USER_NAME}}</p></code> \"Added user name variable\" Rollback to v1 4 \"Welcome!\" <code><p>Hello</p></code> \"Rolled back to version 1\" <p>Note: Rollback creates NEW version (v4 in example), doesn't delete v2 and v3. This preserves complete audit trail.</p>"},{"location":"v2/features/email-templates/versioning/#change-notes","title":"Change Notes","text":"<p>Purpose: Describe what changed and why (audit trail documentation)</p> <p>When Prompted: - EmailTemplateEditorPage shows \"Change Notes\" field on save - Optional but recommended - Stored in <code>changeNotes</code> field</p> <p>Examples:</p> <p>Good Change Notes: <pre><code>- \"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</code></pre></p> <p>Poor Change Notes: <pre><code>- \"update\" (not descriptive)\n- \"changes\" (too vague)\n- \"\" (empty, no context)\n</code></pre></p> <p>Implementation: <pre><code>// 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</code></pre></p>"},{"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":"<p>Step 1: Open Template Detail - EmailTemplatesPage \u2192 click template row - Opens template detail modal</p> <p>Step 2: Navigate to \"Version History\" Tab - Click \"Version History\" tab - Shows table of all versions</p> <p>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</p> <p>Sorting: - Default: Descending by version number (newest first) - Can sort by created date or version number</p>"},{"location":"v2/features/email-templates/versioning/#viewing-version-details","title":"Viewing Version Details","text":"<p>Step 1: Click \"View\" Button - Version history table \u2192 click version row \u2192 \"View\" button</p> <p>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)</p> <p>Step 3: Preview Rendered Version - Click \"Preview\" button - Renders HTML with sample data - Shows how email looked at that version</p>"},{"location":"v2/features/email-templates/versioning/#comparing-versions","title":"Comparing Versions","text":"<p>Step 1: Select Two Versions - Version history table \u2192 checkbox on two version rows - Click \"Compare Selected\" button</p> <p>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</p> <p>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</p> <p>Implementation: <pre><code>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</code></pre></p>"},{"location":"v2/features/email-templates/versioning/#rolling-back-to-previous-version","title":"Rolling Back to Previous Version","text":"<p>Step 1: Select Version to Restore - Version history table \u2192 click version row</p> <p>Step 2: Click \"Restore\" Button - Opens confirmation modal</p> <p>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\")</p> <p>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</p> <p>Rollback Process:</p> <pre><code>// 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</code></pre> <p>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.</p>"},{"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":"<p>When to Use: - Seed script initialization - Programmatic template updates - Testing version history</p> <p>Example: <pre><code>// 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</code></pre></p>"},{"location":"v2/features/email-templates/versioning/#loading-version-history","title":"Loading Version History","text":"<p>Fetch All Versions: <pre><code>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</code></pre></p> <p>Fetch Specific Version: <pre><code>const version = await prisma.emailTemplateVersion.findUnique({\n where: {\n templateId_versionNumber: {\n templateId: 'cuid123',\n versionNumber: 5,\n },\n },\n});\n</code></pre></p> <p>Fetch Latest Version: <pre><code>const latestVersion = await prisma.emailTemplateVersion.findFirst({\n where: { templateId },\n orderBy: { versionNumber: 'desc' },\n});\n\nconsole.log('Latest version:', latestVersion.versionNumber);\n</code></pre></p>"},{"location":"v2/features/email-templates/versioning/#version-diff-generation","title":"Version Diff Generation","text":"<p>Line-by-Line Diff:</p> <pre><code>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</code></pre> <p>Usage: <pre><code>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</code></pre></p> <p>Render Diff in UI: <pre><code>// 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</code></pre></p>"},{"location":"v2/features/email-templates/versioning/#rollback-api-implementation","title":"Rollback API Implementation","text":"<p>Full Rollback Route:</p> <pre><code>// 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</code></pre>"},{"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":"<p>Symptoms: - Duplicate version number error - <code>P2002: Unique constraint failed on templateId_versionNumber</code></p> <p>Causes: 1. Race condition (two saves at same time) 2. Max version query returns wrong result 3. Database constraint violated</p> <p>Solutions:</p> <p>Check max version: <pre><code>SELECT MAX(version_number) FROM email_template_versions\nWHERE template_id = 'cuid123';\n</code></pre></p> <p>Use transaction for atomicity: <pre><code>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</code></pre></p> <p>Reset sequence if needed: <pre><code>-- 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</code></pre></p>"},{"location":"v2/features/email-templates/versioning/#problem-rollback-creates-infinite-versions","title":"Problem: Rollback creates infinite versions","text":"<p>Symptoms: - Rollback triggers another rollback - Version numbers increment rapidly</p> <p>Causes: 1. Rollback doesn't use transaction 2. Version creation triggers template update hook</p> <p>Solutions:</p> <p>Use atomic transaction: <pre><code>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</code></pre></p> <p>Disable hooks during rollback: <pre><code>// 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</code></pre></p>"},{"location":"v2/features/email-templates/versioning/#problem-version-history-shows-duplicate-content","title":"Problem: Version history shows duplicate content","text":"<p>Symptoms: - Multiple versions with identical content - Version numbers increment but content unchanged</p> <p>Causes: 1. Save triggered multiple times (double-click) 2. No dirty check before saving</p> <p>Solutions:</p> <p>Add content comparison before save: <pre><code>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</code></pre></p> <p>Debounce save button: <pre><code>// 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</code></pre></p>"},{"location":"v2/features/email-templates/versioning/#problem-version-comparison-shows-no-diff","title":"Problem: Version comparison shows no diff","text":"<p>Symptoms: - Comparison modal shows identical content - No green/red highlighting</p> <p>Causes: 1. Comparing version with itself 2. Versions truly identical (duplicate save)</p> <p>Solutions:</p> <p>Prevent self-comparison: <pre><code>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</code></pre></p> <p>Check versions exist: <pre><code>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</code></pre></p>"},{"location":"v2/features/email-templates/versioning/#problem-rollback-doesnt-restore-variables","title":"Problem: Rollback doesn't restore variables","text":"<p>Symptoms: - Template content rolled back - Variables not restored (still showing new variables)</p> <p>Causes: - Variables stored separately (not in version snapshot)</p> <p>Current Limitation: - EmailTemplateVersion only stores content (subject, HTML, text) - Does NOT store variable definitions - Rolling back template doesn't affect variables</p> <p>Workaround: - Manually restore variables via admin UI - Future enhancement: Version variable definitions too</p> <p>Future Enhancement: <pre><code>// 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</code></pre></p>"},{"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":"<p>Storage Impact: - Each version stores 3 text fields (subject, HTML, text) - Typical template: 5-20KB per version - 100 versions = 500KB - 2MB per template</p> <p>Optimization Options:</p> <p>1. Compress Old Versions: <pre><code>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</code></pre></p> <p>2. Archive Old Versions: <pre><code>-- 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</code></pre></p> <p>3. Limit Version History: <pre><code>// 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</code></pre></p>"},{"location":"v2/features/email-templates/versioning/#version-diff-performance","title":"Version Diff Performance","text":"<p>Performance Impact: - Diff generation is CPU-intensive for large templates - <code>diffLines</code> algorithm is O(n*m) where n, m = line counts</p> <p>Optimization:</p> <p>1. Cache Diff Results: <pre><code>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</code></pre></p> <p>2. Limit Diff Size: <pre><code>// 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</code></pre></p>"},{"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":"<p>Always Provide Change Notes: - Documents WHY changes were made (not just WHAT) - Helps future admins understand context - Useful for compliance audits</p> <p>Be Specific: <pre><code>\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</code></pre></p> <p>Reference Issues/Tickets: <pre><code>\"Fixed rendering issue in Gmail (Ticket #123)\"\n\"Added new variable per Sarah's request\"\n</code></pre></p>"},{"location":"v2/features/email-templates/versioning/#rollback-safety","title":"Rollback Safety","text":"<p>Always Review Before Rollback: - View version content before restoring - Compare with current version - Understand what will change</p> <p>Use Change Notes: <pre><code>\"Rolled back to version 5 - version 6 broke email rendering in Outlook\"\n</code></pre></p> <p>Test After Rollback: - Send test email after rollback - Verify rendering correct - Check all variables still work</p>"},{"location":"v2/features/email-templates/versioning/#version-retention","title":"Version Retention","text":"<p>Keep All Versions (Default): - Complete audit trail - Compliance requirements</p> <p>Archive Old Versions (Optional): - Templates with 100+ versions - Versions older than 1 year - Move to separate archive table</p> <p>Never Delete Versions: - Breaks audit trail - May violate compliance requirements - Disk space is cheap</p>"},{"location":"v2/features/email-templates/versioning/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/features/email-templates/versioning/#frontend-documentation","title":"Frontend Documentation","text":"<ul> <li>EmailTemplatesPage.tsx \u2014 Version history tab</li> <li>EmailTemplateEditorPage.tsx \u2014 Change notes field</li> </ul>"},{"location":"v2/features/email-templates/versioning/#backend-documentation","title":"Backend Documentation","text":"<ul> <li>Email Templates Module \u2014 Version API routes</li> <li><code>GET /api/email-templates/:id/versions</code> \u2014 List versions</li> <li><code>GET /api/email-templates/:id/versions/:versionNumber</code> \u2014 Get version details</li> <li><code>POST /api/email-templates/:id/rollback/:versionNumber</code> \u2014 Rollback to version</li> </ul>"},{"location":"v2/features/email-templates/versioning/#database-documentation","title":"Database Documentation","text":"<ul> <li>Email Templates Models \u2014 EmailTemplateVersion schema</li> </ul>"},{"location":"v2/features/email-templates/versioning/#feature-documentation","title":"Feature Documentation","text":"<ul> <li>template-system.md \u2014 Email template engine overview</li> <li>editor.md \u2014 Email template editor interface</li> <li>variables.md \u2014 Template variable system</li> </ul>"},{"location":"v2/features/influence/","title":"Influence Module","text":"<p>The Influence module provides a complete advocacy campaign platform for email campaigns, representative lookup, response walls, and engagement tracking. It enables supporters to contact their elected officials on issues that matter.</p>"},{"location":"v2/features/influence/#overview","title":"Overview","text":"<p>The Influence module consists of five integrated components:</p> <ol> <li>Campaigns - Create and manage advocacy email campaigns</li> <li>Representatives - Lookup representatives by postal code</li> <li>Postal Codes - Postal code caching service</li> <li>Email Queue - Async email sending with BullMQ</li> <li>Responses - Public response wall with moderation</li> </ol>"},{"location":"v2/features/influence/#features","title":"Features","text":""},{"location":"v2/features/influence/#campaign-management","title":"Campaign Management","text":"<ul> <li>Create campaigns with title, description, and email template</li> <li>Target federal, provincial, or municipal representatives</li> <li>Track campaign statistics (emails sent, responses)</li> <li>Public/private campaign visibility</li> <li>Featured campaign highlighting</li> </ul>"},{"location":"v2/features/influence/#representative-lookup","title":"Representative Lookup","text":"<ul> <li>Represent API integration (federal/provincial)</li> <li>Postal code \u2192 representative matching</li> <li>Representative information caching</li> <li>Multiple representative levels</li> <li>District boundary support</li> </ul>"},{"location":"v2/features/influence/#email-sending","title":"Email Sending","text":"<ul> <li>Async email queue with BullMQ</li> <li>Template processing with variable substitution</li> <li>SMTP delivery with retry logic</li> <li>Email tracking and statistics</li> <li>Test mode support (MailHog)</li> </ul>"},{"location":"v2/features/influence/#response-wall","title":"Response Wall","text":"<ul> <li>Public response submissions</li> <li>Email verification flow</li> <li>Moderation dashboard</li> <li>Upvoting system</li> <li>Response filtering and export</li> </ul>"},{"location":"v2/features/influence/#user-flow","title":"User Flow","text":""},{"location":"v2/features/influence/#public-user-experience","title":"Public User Experience","text":"<ol> <li>Browse Campaigns (<code>/campaigns</code>)</li> <li>View featured campaigns</li> <li>Search and filter (future)</li> <li> <p>Click campaign to learn more</p> </li> <li> <p>Campaign Detail (<code>/campaigns/:id</code>)</p> </li> <li>Read campaign description</li> <li>Enter postal code</li> <li>View matched representatives</li> <li>Customize email message</li> <li> <p>Send email</p> </li> <li> <p>Response Wall (<code>/responses/:campaignId</code>)</p> </li> <li>Submit public response</li> <li>Verify email address</li> <li>View verified responses</li> <li>Upvote responses</li> </ol>"},{"location":"v2/features/influence/#admin-experience","title":"Admin Experience","text":"<ol> <li>Campaign Management (<code>/app/influence/campaigns</code>)</li> <li>Create campaigns</li> <li>Edit templates</li> <li>Configure targeting</li> <li>View statistics</li> <li> <p>Manage visibility</p> </li> <li> <p>Response Moderation (<code>/app/influence/responses</code>)</p> </li> <li>Review submissions</li> <li>Verify/reject responses</li> <li>Export data</li> <li> <p>Monitor engagement</p> </li> <li> <p>Representative Cache (<code>/app/influence/representatives</code>)</p> </li> <li>View cached representatives</li> <li>Refresh cache</li> <li> <p>Monitor lookup statistics</p> </li> <li> <p>Email Queue (<code>/app/influence/email-queue</code>)</p> </li> <li>Monitor queue status</li> <li>View failed jobs</li> <li>Retry failed emails</li> <li>Pause/resume queue</li> </ol>"},{"location":"v2/features/influence/#architecture","title":"Architecture","text":""},{"location":"v2/features/influence/#backend-components","title":"Backend Components","text":"<p>Modules: - <code>api/src/modules/influence/campaigns/</code> - Campaign CRUD + public routes - <code>api/src/modules/influence/representatives/</code> - Represent API integration - <code>api/src/modules/influence/postal-codes/</code> - Postal code cache service - <code>api/src/modules/influence/responses/</code> - Response CRUD + verification - <code>api/src/modules/influence/campaign-emails/</code> - Email tracking - <code>api/src/modules/influence/email-queue/</code> - Queue admin routes</p> <p>Services: - <code>api/src/services/email.service.ts</code> - Nodemailer wrapper - <code>api/src/services/email-queue.service.ts</code> - BullMQ queue + worker</p> <p>Database Models: - <code>Campaign</code> - Campaign definitions - <code>CampaignEmail</code> - Sent email tracking - <code>Response</code> - Public response submissions - <code>PostalCodeCache</code> - Cached representative data</p>"},{"location":"v2/features/influence/#frontend-components","title":"Frontend Components","text":"<p>Admin Pages: - <code>admin/src/pages/CampaignsPage.tsx</code> - Campaign management - <code>admin/src/pages/ResponsesPage.tsx</code> - Response moderation - <code>admin/src/pages/RepresentativesPage.tsx</code> - Cache admin - <code>admin/src/pages/EmailQueuePage.tsx</code> - Queue monitoring</p> <p>Public Pages: - <code>admin/src/pages/public/CampaignsListPage.tsx</code> - Campaign listing - <code>admin/src/pages/public/CampaignPage.tsx</code> - Campaign detail + email form - <code>admin/src/pages/public/ResponseWallPage.tsx</code> - Response submissions</p>"},{"location":"v2/features/influence/#configuration","title":"Configuration","text":""},{"location":"v2/features/influence/#environment-variables","title":"Environment Variables","text":"<pre><code># 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</code></pre>"},{"location":"v2/features/influence/#feature-flags","title":"Feature Flags","text":"<p>Email sending can be toggled via <code>EMAIL_TEST_MODE</code>: - <code>true</code> - Emails sent to MailHog (localhost:8025) - <code>false</code> - Emails sent via SMTP</p>"},{"location":"v2/features/influence/#integration-points","title":"Integration Points","text":""},{"location":"v2/features/influence/#represent-api","title":"Represent API","text":"<p>Represent API (https://represent.opennorth.ca/) provides: - Federal MP lookup by postal code - Provincial MLA/MPP lookup - District boundaries - Representative contact info</p> <p>Rate Limits: 60 requests/minute</p> <p>Caching Strategy: - Cache postal code \u2192 representative mappings - Refresh cache on 404 (postal code not found) - Cache expiration: 30 days</p>"},{"location":"v2/features/influence/#listmonk-newsletter-sync","title":"Listmonk Newsletter Sync","text":"<p>Campaign participants can be synced to Listmonk: - Email submissions \u2192 subscribers - Campaign \u2192 list assignment - Opt-in sync via <code>LISTMONK_SYNC_ENABLED</code></p>"},{"location":"v2/features/influence/#email-queue-bullmq","title":"Email Queue (BullMQ)","text":"<p>BullMQ provides: - Async email processing - Job retry with exponential backoff - Queue monitoring and statistics - Job persistence in Redis</p>"},{"location":"v2/features/influence/#api-endpoints","title":"API Endpoints","text":""},{"location":"v2/features/influence/#public-endpoints","title":"Public Endpoints","text":"<pre><code>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</code></pre>"},{"location":"v2/features/influence/#admin-endpoints","title":"Admin Endpoints","text":"<pre><code>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</code></pre>"},{"location":"v2/features/influence/#related-documentation","title":"Related Documentation","text":"<ul> <li>Campaigns</li> <li>Representatives</li> <li>Email Queue</li> <li>Responses</li> <li>Backend Campaign Module</li> <li>Backend Representatives Module</li> <li>Backend Responses Module</li> <li>Email Service</li> <li>Campaign Manager Guide</li> </ul>"},{"location":"v2/features/influence/campaigns/","title":"Campaign Management System","text":""},{"location":"v2/features/influence/campaigns/#overview","title":"Overview","text":"<p>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.</p> <p>Key Capabilities:</p> <ul> <li>Multi-status lifecycle: Draft \u2192 Active \u2192 Paused \u2192 Archived workflow</li> <li>12 feature flags: Granular control over campaign behavior</li> <li>Government level filtering: Target specific levels (federal, provincial, municipal)</li> <li>Cover photo uploads: Visual campaign branding</li> <li>Slug-based routing: SEO-friendly public URLs</li> <li>Response wall integration: Public display of campaign responses</li> <li>Email tracking: Monitor sent emails and campaign effectiveness</li> </ul> <p>Use Cases:</p> <ul> <li>Advocacy campaigns targeting elected officials</li> <li>Public awareness campaigns with response sharing</li> <li>Email-your-MP initiatives</li> <li>Multi-level government outreach</li> <li>Time-limited advocacy actions</li> </ul>"},{"location":"v2/features/influence/campaigns/#architecture","title":"Architecture","text":"<pre><code>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</code></pre> <p>Flow Description:</p> <ol> <li>Admin creates campaign \u2192 Campaign service validates and saves to database</li> <li>Public user browses \u2192 Campaign service returns active campaigns</li> <li>User views campaign \u2192 Representatives service looks up postal code</li> <li>User sends email \u2192 Email queue service adds job to BullMQ</li> <li>Worker processes job \u2192 Email sent via SMTP, tracked in CampaignEmail model</li> <li>User submits response \u2192 Response service creates response for moderation</li> </ol>"},{"location":"v2/features/influence/campaigns/#database-models","title":"Database Models","text":""},{"location":"v2/features/influence/campaigns/#campaign-model","title":"Campaign Model","text":"<p>See Campaign Model Documentation for full schema.</p> <p>Key Fields:</p> <ul> <li><code>status</code>: DRAFT | ACTIVE | PAUSED | ARCHIVED</li> <li><code>targetGovernmentLevels</code>: Array of government levels (federal, provincial, municipal)</li> <li><code>emailSubjectTemplate</code>: Subject line with {{VAR}} placeholders</li> <li><code>emailBodyTemplate</code>: Email body with {{VAR}} placeholders</li> <li><code>coverPhotoUrl</code>: Campaign hero image URL</li> <li><code>slug</code>: URL-friendly identifier</li> </ul> <p>Feature Flags (12 total):</p> Flag Type Default Description <code>allowSmtpEmail</code> boolean true Enable email sending <code>allowCallTracking</code> boolean false Enable phone call logging <code>showResponseWall</code> boolean true Display response wall <code>requireEmailVerification</code> boolean true Verify response emails <code>allowAnonymousResponses</code> boolean false Allow responses without login <code>highlightCampaign</code> boolean false Feature on homepage <code>showProgressBar</code> boolean true Display response count progress <code>allowSharing</code> boolean true Enable social sharing buttons <code>requirePostalCode</code> boolean true Require postal code for lookup <code>allowCustomMessage</code> boolean true Users can edit email text <code>trackEmailOpens</code> boolean false Track email opens (future) <code>notifyOnResponse</code> boolean true Email admin on new responses <p>Related Models:</p> <ul> <li>CampaignEmail \u2014 Tracks sent emails</li> <li>Response \u2014 Public responses to campaign</li> <li>Representative \u2014 Email recipients</li> </ul>"},{"location":"v2/features/influence/campaigns/#api-endpoints","title":"API Endpoints","text":""},{"location":"v2/features/influence/campaigns/#admin-endpoints","title":"Admin Endpoints","text":"<p>See Campaigns Module API Reference for full details.</p> Method Endpoint Auth Description GET <code>/api/campaigns</code> SUPER_ADMIN, INFLUENCE_ADMIN List all campaigns (paginated) GET <code>/api/campaigns/:id</code> SUPER_ADMIN, INFLUENCE_ADMIN Get campaign details POST <code>/api/campaigns</code> SUPER_ADMIN, INFLUENCE_ADMIN Create new campaign PUT <code>/api/campaigns/:id</code> SUPER_ADMIN, INFLUENCE_ADMIN Update campaign PATCH <code>/api/campaigns/:id/status</code> SUPER_ADMIN, INFLUENCE_ADMIN Update campaign status DELETE <code>/api/campaigns/:id</code> SUPER_ADMIN Delete campaign"},{"location":"v2/features/influence/campaigns/#public-endpoints","title":"Public Endpoints","text":"<p>See Campaigns Public API Reference.</p> Method Endpoint Auth Description GET <code>/api/public/campaigns</code> None List active campaigns GET <code>/api/public/campaigns/:slug</code> 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 <code>EMAIL_TEST_MODE</code> boolean false Send emails to MailHog instead of SMTP <code>SMTP_HOST</code> string - SMTP server hostname <code>SMTP_PORT</code> number 587 SMTP server port <code>SMTP_USER</code> string - SMTP username <code>SMTP_PASS</code> string - SMTP password <code>SMTP_FROM_EMAIL</code> string - Default sender email <code>SMTP_FROM_NAME</code> string - Default sender name"},{"location":"v2/features/influence/campaigns/#site-settings","title":"Site Settings","text":"<p>SMTP settings can be configured via Site Settings (overrides env vars):</p> <pre><code>{\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</code></pre>"},{"location":"v2/features/influence/campaigns/#upload-configuration","title":"Upload Configuration","text":"<p>Cover photos uploaded to <code>/uploads/campaigns/{campaignId}/{filename}</code>.</p> <p>Limits: - Max file size: 10MB - Allowed formats: jpg, jpeg, png, gif, webp</p>"},{"location":"v2/features/influence/campaigns/#admin-workflow","title":"Admin Workflow","text":""},{"location":"v2/features/influence/campaigns/#1-create-campaign","title":"1. Create Campaign","text":"<p>[Screenshot: CampaignsPage with \"Create Campaign\" button]</p> <p>Steps:</p> <ol> <li>Navigate to Influence > Campaigns</li> <li>Click Create Campaign button</li> <li>Fill in campaign details:</li> <li>Title (required)</li> <li>Description (required)</li> <li>Target government levels (select all that apply)</li> <li>Email subject template (use {{VAR}} for dynamic content)</li> <li>Email body template (HTML supported)</li> <li>Upload cover photo (optional)</li> <li>Click Save (saves as DRAFT)</li> </ol> <p>Code Example (CampaignsPage.tsx):</p> <pre><code>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</code></pre>"},{"location":"v2/features/influence/campaigns/#2-configure-feature-flags","title":"2. Configure Feature Flags","text":"<p>[Screenshot: Campaign edit modal with feature flags section]</p> <p>Steps:</p> <ol> <li>Click Edit on campaign row</li> <li>Scroll to Feature Flags section</li> <li>Toggle flags as needed:</li> <li>allowSmtpEmail: Enable email sending (required for email campaigns)</li> <li>showResponseWall: Display public response wall</li> <li>requireEmailVerification: Require email verification for responses</li> <li>highlightCampaign: Feature on homepage</li> <li>allowCustomMessage: Let users edit email text before sending</li> <li>Click Save</li> </ol> <p>Best Practices:</p> <ul> <li>Enable <code>requireEmailVerification</code> for public response walls</li> <li>Disable <code>allowCustomMessage</code> if you want consistent messaging</li> <li>Use <code>highlightCampaign</code> sparingly (max 2-3 campaigns)</li> <li>Enable <code>showProgressBar</code> to encourage participation</li> </ul>"},{"location":"v2/features/influence/campaigns/#3-test-campaign","title":"3. Test Campaign","text":"<p>[Screenshot: Campaign preview with test email form]</p> <p>Steps:</p> <ol> <li>Set campaign status to ACTIVE</li> <li>Navigate to public campaign page: <code>/campaigns/{slug}</code></li> <li>Enter test postal code</li> <li>Review representative lookup results</li> <li>Send test email to your own email address</li> <li>Verify email content and formatting</li> </ol> <p>Troubleshooting:</p> <ul> <li>If no representatives found \u2192 Check Represent API cache</li> <li>If email not received \u2192 Check Email Queue page for job status</li> <li>If email formatting broken \u2192 Review HTML template syntax</li> </ul>"},{"location":"v2/features/influence/campaigns/#4-publish-campaign","title":"4. Publish Campaign","text":"<p>[Screenshot: Campaign status dropdown]</p> <p>Steps:</p> <ol> <li>Return to Campaigns page</li> <li>Click Status dropdown on campaign row</li> <li>Select ACTIVE</li> <li>Campaign now visible on public campaigns page</li> </ol> <p>Status Lifecycle:</p> <pre><code>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 --> [*]</code></pre>"},{"location":"v2/features/influence/campaigns/#5-monitor-campaign","title":"5. Monitor Campaign","text":"<p>[Screenshot: Campaign emails drawer with stats]</p> <p>Steps:</p> <ol> <li>Click View Emails on campaign row</li> <li>Review email stats:</li> <li>Total sent</li> <li>Success rate</li> <li>Failed emails</li> <li>View individual email details (recipient, status, sent date)</li> <li>Retry failed emails if needed</li> </ol> <p>Metrics to Track:</p> <ul> <li>Emails sent per day</li> <li>Response wall submissions</li> <li>Verification rate (if enabled)</li> <li>Geographic distribution (via postal codes)</li> </ul>"},{"location":"v2/features/influence/campaigns/#public-workflow","title":"Public Workflow","text":""},{"location":"v2/features/influence/campaigns/#1-browse-campaigns","title":"1. Browse Campaigns","text":"<p>[Screenshot: Public campaigns list page with featured campaigns]</p> <p>User Journey:</p> <ol> <li>User visits <code>/campaigns</code></li> <li>Sees featured campaigns (if <code>highlightCampaign</code> enabled)</li> <li>Browses active campaigns grid</li> <li>Clicks campaign card to view details</li> </ol> <p>Code Example (CampaignsListPage.tsx):</p> <pre><code>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</code></pre>"},{"location":"v2/features/influence/campaigns/#2-view-campaign-details","title":"2. View Campaign Details","text":"<p>[Screenshot: Campaign detail page with postal code lookup form]</p> <p>User Journey:</p> <ol> <li>User clicks campaign card</li> <li>Navigated to <code>/campaigns/{slug}</code></li> <li>Reads campaign description</li> <li>Enters postal code in lookup form</li> <li>System fetches representatives from Represent API</li> <li>User selects representatives to email</li> </ol>"},{"location":"v2/features/influence/campaigns/#3-send-email","title":"3. Send Email","text":"<p>[Screenshot: Email form with representative selection]</p> <p>User Journey:</p> <ol> <li>User reviews list of representatives</li> <li>Selects representatives to email (checkboxes)</li> <li>Reviews email subject and body</li> <li>Edits message if <code>allowCustomMessage</code> enabled</li> <li>Adds personal details (name, email)</li> <li>Clicks Send Email</li> <li>Email jobs added to BullMQ queue</li> <li>User sees confirmation message</li> </ol> <p>Code Example (CampaignPage.tsx):</p> <pre><code>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</code></pre>"},{"location":"v2/features/influence/campaigns/#4-submit-response-optional","title":"4. Submit Response (Optional)","text":"<p>[Screenshot: Response submission form]</p> <p>User Journey:</p> <ol> <li>After sending email, user clicks Share Your Response</li> <li>Navigated to <code>/responses/{campaignId}/submit</code></li> <li>Fills in response form:</li> <li>Type (EMAIL, LETTER, PHONE_CALL, etc.)</li> <li>Message</li> <li>Screenshot (optional)</li> <li>Submits response</li> <li>If <code>requireEmailVerification</code> enabled \u2192 verification email sent</li> <li>User clicks verification link in email</li> <li>Response appears on public response wall (after admin approval if moderation enabled)</li> </ol>"},{"location":"v2/features/influence/campaigns/#volunteer-workflow","title":"Volunteer Workflow","text":"<p>Not applicable \u2014 campaigns are admin-managed and public-facing.</p>"},{"location":"v2/features/influence/campaigns/#code-examples","title":"Code Examples","text":""},{"location":"v2/features/influence/campaigns/#backend-create-campaign","title":"Backend: Create Campaign","text":"<pre><code>// 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</code></pre>"},{"location":"v2/features/influence/campaigns/#frontend-campaign-card-component","title":"Frontend: Campaign Card Component","text":"<pre><code>// 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</code></pre>"},{"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":"<p>Symptoms: - Campaign exists in admin but doesn't appear on <code>/campaigns</code></p> <p>Solutions:</p> <ol> <li>Check campaign status \u2192 must be <code>ACTIVE</code></li> <li>Verify no draft campaigns leaked \u2192 filter by status in query</li> <li>Check Nginx caching \u2192 clear cache or disable for <code>/api/public/campaigns</code></li> </ol> <p>Debugging:</p> <pre><code># 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</code></pre>"},{"location":"v2/features/influence/campaigns/#email-template-variables-not-replaced","title":"Email Template Variables Not Replaced","text":"<p>Symptoms: - Email sent with <code>{{senderName}}</code> instead of actual name</p> <p>Solutions:</p> <ol> <li>Verify variable syntax \u2192 must use double curly braces <code>{{VAR}}</code></li> <li>Check email service interpolation \u2192 ensure <code>processTemplate()</code> called</li> <li>Verify variable names match \u2192 <code>senderName</code>, <code>senderEmail</code>, <code>postalCode</code>, <code>recipientName</code>, <code>recipientEmail</code></li> </ol> <p>Code Fix (email.service.ts):</p> <pre><code>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</code></pre>"},{"location":"v2/features/influence/campaigns/#cover-photo-upload-fails","title":"Cover Photo Upload Fails","text":"<p>Symptoms: - Upload spinner never completes - Error: \"File too large\"</p> <p>Solutions:</p> <ol> <li>Check file size \u2192 max 10MB</li> <li>Verify file format \u2192 must be jpg/jpeg/png/gif/webp</li> <li>Check upload directory permissions \u2192 <code>/uploads/campaigns</code> must be writable</li> <li>Increase Nginx upload limit \u2192 <code>client_max_body_size 20M;</code></li> </ol> <p>Docker Volume Fix:</p> <pre><code># docker-compose.yml\nservices:\n api:\n volumes:\n - ./uploads:/app/uploads:rw # Ensure :rw (read-write)\n</code></pre>"},{"location":"v2/features/influence/campaigns/#representatives-not-loading","title":"Representatives Not Loading","text":"<p>Symptoms: - Postal code lookup returns empty array</p> <p>Solutions:</p> <ol> <li>Check Represent API status \u2192 visit https://represent.opennorth.ca/health</li> <li>Verify postal code format \u2192 must be valid Canadian postal code (K1A 0A1)</li> <li>Check representative cache \u2192 may need refresh</li> <li>Review API rate limits \u2192 Represent API has rate limits</li> </ol> <p>Manual Cache Refresh:</p> <pre><code># 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</code></pre>"},{"location":"v2/features/influence/campaigns/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/features/influence/campaigns/#campaign-listing-optimization","title":"Campaign Listing Optimization","text":"<p>Query Optimization:</p> <pre><code>// 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</code></pre> <p>Caching Strategy:</p> <ul> <li>Cache active campaigns list for 5 minutes (Redis)</li> <li>Invalidate cache on campaign status change</li> <li>Use ETags for HTTP caching</li> </ul>"},{"location":"v2/features/influence/campaigns/#email-queue-scaling","title":"Email Queue Scaling","text":"<p>BullMQ Configuration:</p> <pre><code>// 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</code></pre> <p>Monitoring:</p> <ul> <li>Track queue size with Prometheus <code>cm_email_queue_size</code> metric</li> <li>Alert if queue size > 1000</li> <li>Monitor worker processing rate</li> </ul>"},{"location":"v2/features/influence/campaigns/#cover-photo-optimization","title":"Cover Photo Optimization","text":"<p>Image Processing:</p> <pre><code>// 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</code></pre> <p>CDN Integration:</p> <ul> <li>Serve cover photos via CDN (Cloudflare, CloudFront)</li> <li>Use responsive images with <code>srcset</code></li> <li>Lazy load images below fold</li> </ul>"},{"location":"v2/features/influence/campaigns/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/features/influence/campaigns/#backend-modules","title":"Backend Modules","text":"<ul> <li>Campaigns Module \u2014 Full API reference</li> <li>Representatives Module \u2014 Represent API integration</li> <li>Responses Module \u2014 Response wall system</li> <li>Email Queue Module \u2014 BullMQ email processing</li> </ul>"},{"location":"v2/features/influence/campaigns/#frontend-pages","title":"Frontend Pages","text":"<ul> <li>CampaignsPage \u2014 Admin campaign management</li> <li>CampaignPage \u2014 Public campaign view</li> <li>CampaignsListPage \u2014 Public campaign listing</li> <li>ResponsesPage \u2014 Response moderation</li> </ul>"},{"location":"v2/features/influence/campaigns/#database-models_1","title":"Database Models","text":"<ul> <li>Campaign \u2014 Campaign schema</li> <li>CampaignEmail \u2014 Email tracking schema</li> <li>Response \u2014 Response schema</li> <li>Representative \u2014 Representative schema</li> </ul>"},{"location":"v2/features/influence/campaigns/#configuration_1","title":"Configuration","text":"<ul> <li>Environment Variables \u2014 SMTP configuration</li> <li>Site Settings \u2014 Global settings API</li> </ul>"},{"location":"v2/features/influence/campaigns/#guides","title":"Guides","text":"<ul> <li>Email Sending Guide \u2014 Email queue and BullMQ</li> <li>Response Wall Guide \u2014 Response moderation workflow</li> <li>Representative Lookup Guide \u2014 Represent API integration</li> </ul>"},{"location":"v2/features/influence/email-queue/","title":"Email Queue System","text":""},{"location":"v2/features/influence/email-queue/#overview","title":"Overview","text":"<p>The email queue system manages asynchronous email sending for advocacy campaigns using BullMQ and Redis. It provides reliable email delivery, retry logic, job monitoring, and comprehensive tracking of email campaign effectiveness.</p> <p>Key Capabilities:</p> <ul> <li>BullMQ integration: Redis-backed job queue for email processing</li> <li>Automatic retry logic: Failed emails retried with exponential backoff</li> <li>Job status tracking: Monitor queued, active, completed, and failed jobs</li> <li>Rate limiting: Prevent SMTP server overload</li> <li>Email tracking: Track sent emails per campaign</li> <li>Admin monitoring: Real-time queue statistics and job management</li> <li>Test mode: Send to MailHog instead of SMTP for testing</li> </ul> <p>Use Cases:</p> <ul> <li>Bulk email sending for advocacy campaigns</li> <li>Reliable email delivery with retry</li> <li>Email campaign effectiveness tracking</li> <li>SMTP server load management</li> <li>Development email testing</li> </ul>"},{"location":"v2/features/influence/email-queue/#architecture","title":"Architecture","text":"<pre><code>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</code></pre> <p>Flow Description:</p> <ol> <li>User sends email \u2192 Campaign service adds job to BullMQ queue</li> <li>Worker polls queue \u2192 Picks up job for processing</li> <li>Email sent via SMTP \u2192 Nodemailer sends email</li> <li>Success \u2192 Job marked completed, email tracked in database</li> <li>Failure \u2192 Job retried with exponential backoff (3 attempts)</li> <li>Admin monitors \u2192 View queue stats, pause/resume, clean old jobs</li> </ol>"},{"location":"v2/features/influence/email-queue/#database-models","title":"Database Models","text":""},{"location":"v2/features/influence/email-queue/#campaignemail-model","title":"CampaignEmail Model","text":"<p>See CampaignEmail Model Documentation for full schema.</p> <p>Key Fields:</p> Field Type Description <code>id</code> String (UUID) Primary key <code>campaignId</code> String Associated campaign <code>recipientEmail</code> String Email recipient <code>recipientName</code> String? Recipient name <code>senderEmail</code> String Sender email address <code>senderName</code> String Sender name <code>subject</code> String Email subject line <code>body</code> String (Text) Email body content <code>status</code> Enum QUEUED, SENT, FAILED <code>jobId</code> String? BullMQ job ID <code>sentAt</code> DateTime? When email was sent <code>failureReason</code> String? Error message if failed <p>Indexes:</p> <ul> <li><code>campaignId, status</code> \u2014 For campaign email stats</li> <li><code>jobId</code> \u2014 For job status lookups</li> <li><code>sentAt</code> \u2014 For time-based queries</li> </ul> <p>Related Models:</p> <ul> <li>Campaign \u2014 Campaign association</li> <li>Representative \u2014 Email recipients</li> </ul>"},{"location":"v2/features/influence/email-queue/#api-endpoints","title":"API Endpoints","text":""},{"location":"v2/features/influence/email-queue/#admin-endpoints","title":"Admin Endpoints","text":"<p>See Email Queue Module API Reference for full details.</p> Method Endpoint Auth Description GET <code>/api/email-queue/stats</code> SUPER_ADMIN, INFLUENCE_ADMIN Get queue statistics POST <code>/api/email-queue/pause</code> SUPER_ADMIN, INFLUENCE_ADMIN Pause queue processing POST <code>/api/email-queue/resume</code> SUPER_ADMIN, INFLUENCE_ADMIN Resume queue processing POST <code>/api/email-queue/clean</code> SUPER_ADMIN Clean completed/failed jobs POST <code>/api/email-queue/retry/:jobId</code> SUPER_ADMIN, INFLUENCE_ADMIN Retry failed job"},{"location":"v2/features/influence/email-queue/#public-endpoints","title":"Public Endpoints","text":"<p>Email queue jobs are created via campaign email endpoints (no direct public access).</p>"},{"location":"v2/features/influence/email-queue/#configuration","title":"Configuration","text":""},{"location":"v2/features/influence/email-queue/#environment-variables","title":"Environment Variables","text":"Variable Type Default Description <code>REDIS_HOST</code> string localhost Redis hostname <code>REDIS_PORT</code> number 6379 Redis port <code>REDIS_PASSWORD</code> string - Redis password (required) <code>SMTP_HOST</code> string - SMTP server hostname <code>SMTP_PORT</code> number 587 SMTP server port <code>SMTP_USER</code> string - SMTP username <code>SMTP_PASS</code> string - SMTP password <code>SMTP_FROM_EMAIL</code> string - Default sender email <code>SMTP_FROM_NAME</code> string - Default sender name <code>EMAIL_TEST_MODE</code> boolean false Send to MailHog instead of SMTP <code>EMAIL_QUEUE_CONCURRENCY</code> number 5 Max concurrent email workers"},{"location":"v2/features/influence/email-queue/#bullmq-configuration","title":"BullMQ Configuration","text":"<pre><code>// 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</code></pre>"},{"location":"v2/features/influence/email-queue/#worker-configuration","title":"Worker Configuration","text":"<pre><code>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</code></pre>"},{"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":"<p>[Screenshot: EmailQueuePage with queue stats cards]</p> <p>Steps:</p> <ol> <li>Navigate to Influence > Email Queue</li> <li>View queue statistics:</li> <li>Waiting: Jobs queued for processing</li> <li>Active: Jobs currently being processed</li> <li>Completed: Successfully sent emails</li> <li>Failed: Failed emails requiring attention</li> <li>Monitor queue health (green if waiting < 100)</li> </ol> <p>Code Example (EmailQueuePage.tsx):</p> <pre><code>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</code></pre>"},{"location":"v2/features/influence/email-queue/#2-pauseresume-queue","title":"2. Pause/Resume Queue","text":"<p>[Screenshot: EmailQueuePage with pause/resume buttons]</p> <p>Steps:</p> <ol> <li>Click Pause Queue button</li> <li>Queue stops processing new jobs</li> <li>Active jobs complete normally</li> <li>Status indicator shows \"Paused\"</li> <li>Click Resume Queue to restart processing</li> </ol> <p>Use Cases:</p> <ul> <li>Temporary SMTP server maintenance</li> <li>Stop email sending during testing</li> <li>Prevent email sending during off-hours</li> </ul> <p>Code Example (email-queue.service.ts):</p> <pre><code>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</code></pre>"},{"location":"v2/features/influence/email-queue/#3-clean-completed-jobs","title":"3. Clean Completed Jobs","text":"<p>[Screenshot: EmailQueuePage with clean jobs button]</p> <p>Steps:</p> <ol> <li>Click Clean Jobs dropdown</li> <li>Select cleanup type:</li> <li>Completed (>24h): Remove old successful jobs</li> <li>Failed (>7d): Remove old failed jobs</li> <li>All Completed: Remove all successful jobs</li> <li>Confirm cleanup</li> <li>Jobs removed from queue, stats updated</li> </ol> <p>Code Example (email-queue.routes.ts):</p> <pre><code>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</code></pre>"},{"location":"v2/features/influence/email-queue/#4-retry-failed-jobs","title":"4. Retry Failed Jobs","text":"<p>[Screenshot: Failed jobs table with retry buttons]</p> <p>Steps:</p> <ol> <li>Scroll to Failed Jobs section</li> <li>View failed job details (error message, recipient)</li> <li>Click Retry button on specific job</li> <li>Job re-queued for processing</li> <li>Monitor in Active tab</li> </ol> <p>Bulk Retry:</p> <ol> <li>Select multiple failed jobs (checkboxes)</li> <li>Click Retry Selected button</li> <li>All selected jobs re-queued</li> </ol> <p>Code Example (email-queue.service.ts):</p> <pre><code>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</code></pre>"},{"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":"<p>[Screenshot: CampaignPage with email sending form]</p> <p>User Journey:</p> <ol> <li>User selects representatives to email</li> <li>Fills in sender details (name, email)</li> <li>Reviews/edits email content (if allowed)</li> <li>Clicks Send Email button</li> <li>System creates email jobs (one per recipient)</li> <li>Jobs added to BullMQ queue</li> <li>User sees confirmation message</li> </ol> <p>Code Example (campaigns-public.routes.ts):</p> <pre><code>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</code></pre>"},{"location":"v2/features/influence/email-queue/#2-job-processing","title":"2. Job Processing","text":"<p>Worker Processing Logic:</p> <pre><code>// 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</code></pre>"},{"location":"v2/features/influence/email-queue/#volunteer-workflow","title":"Volunteer Workflow","text":"<p>Not applicable \u2014 email queue is system-level.</p>"},{"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":"<pre><code>// 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</code></pre>"},{"location":"v2/features/influence/email-queue/#frontend-queue-stats-dashboard","title":"Frontend: Queue Stats Dashboard","text":"<pre><code>// 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</code></pre>"},{"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":"<p>Symptoms: - Waiting count increases but active/completed don't - Jobs not processing</p> <p>Solutions:</p> <ol> <li>Check worker status \u2192 <code>docker compose logs api | grep \"Worker\"</code></li> <li>Verify Redis connection \u2192 <code>docker compose exec redis redis-cli ping</code></li> <li>Check SMTP configuration \u2192 test with <code>/api/auth/test-email</code></li> <li>Restart worker \u2192 <code>docker compose restart api</code></li> </ol> <p>Debugging:</p> <pre><code># 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</code></pre>"},{"location":"v2/features/influence/email-queue/#high-failure-rate","title":"High Failure Rate","text":"<p>Symptoms: - Many jobs failing - Failed count increasing rapidly</p> <p>Solutions:</p> <ol> <li>Check SMTP credentials \u2192 verify username/password</li> <li>Review failure reasons \u2192 check <code>failureReason</code> field in database</li> <li>Check SMTP server status \u2192 verify server is reachable</li> <li>Review rate limits \u2192 may be hitting SMTP server limits</li> </ol> <p>Common Failure Reasons:</p> <ul> <li>535 Authentication failed \u2192 Invalid SMTP credentials</li> <li>550 Mailbox unavailable \u2192 Recipient email doesn't exist</li> <li>421 Too many connections \u2192 Reduce concurrency</li> <li>Connection timeout \u2192 SMTP server unreachable</li> </ul> <p>Code Fix (email.service.ts):</p> <pre><code>// 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</code></pre>"},{"location":"v2/features/influence/email-queue/#redis-connection-issues","title":"Redis Connection Issues","text":"<p>Symptoms: - Error: \"ECONNREFUSED\" or \"NOAUTH\" - Queue operations fail</p> <p>Solutions:</p> <ol> <li>Verify Redis is running \u2192 <code>docker compose ps redis</code></li> <li>Check Redis password \u2192 ensure <code>REDIS_PASSWORD</code> matches docker-compose.yml</li> <li>Check Redis port \u2192 default 6379</li> <li>Verify Redis auth \u2192 <code>docker compose exec redis redis-cli --pass $REDIS_PASSWORD ping</code></li> </ol> <p>Fix Redis Auth:</p> <pre><code># docker-compose.yml\nservices:\n redis:\n image: redis:7-alpine\n command: redis-server --requirepass ${REDIS_PASSWORD}\n ports:\n - \"6379:6379\"\n</code></pre>"},{"location":"v2/features/influence/email-queue/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/features/influence/email-queue/#concurrency-tuning","title":"Concurrency Tuning","text":"<p>Worker Concurrency:</p> <pre><code>// 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</code></pre> <p>SMTP Server Limits:</p> <ul> <li>Gmail: 100 emails/day (consumer), 2000/day (Workspace)</li> <li>SendGrid: Varies by plan (40k/day free tier)</li> <li>AWS SES: 14 emails/second, 200 emails/day (sandbox)</li> </ul>"},{"location":"v2/features/influence/email-queue/#queue-monitoring","title":"Queue Monitoring","text":"<p>Prometheus Metrics:</p> <pre><code>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</code></pre>"},{"location":"v2/features/influence/email-queue/#database-optimization","title":"Database Optimization","text":"<p>Index Strategy:</p> <pre><code>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</code></pre> <p>Query Optimization:</p> <pre><code>// 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</code></pre>"},{"location":"v2/features/influence/email-queue/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/features/influence/email-queue/#backend-modules","title":"Backend Modules","text":"<ul> <li>Email Queue Module \u2014 Full API reference</li> <li>Email Service \u2014 SMTP configuration</li> <li>Campaigns Module \u2014 Campaign integration</li> </ul>"},{"location":"v2/features/influence/email-queue/#frontend-pages","title":"Frontend Pages","text":"<ul> <li>EmailQueuePage \u2014 Admin queue monitoring</li> <li>CampaignsPage \u2014 Campaign management</li> </ul>"},{"location":"v2/features/influence/email-queue/#database-models_1","title":"Database Models","text":"<ul> <li>CampaignEmail \u2014 Email tracking schema</li> <li>Campaign \u2014 Campaign schema</li> </ul>"},{"location":"v2/features/influence/email-queue/#configuration_1","title":"Configuration","text":"<ul> <li>Environment Variables \u2014 SMTP/Redis configuration</li> <li>BullMQ Documentation \u2014 Official BullMQ docs</li> </ul>"},{"location":"v2/features/influence/email-queue/#monitoring","title":"Monitoring","text":"<ul> <li>Prometheus Metrics \u2014 Email queue metrics</li> <li>Grafana Dashboards \u2014 Queue visualization</li> </ul>"},{"location":"v2/features/influence/postal-codes/","title":"Postal Code Geocoding Cache","text":""},{"location":"v2/features/influence/postal-codes/#overview","title":"Overview","text":"<p>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.</p> <p>Key Capabilities:</p> <ul> <li>Postal code caching: Store lat/lng centroids for postal codes</li> <li>Geocoding integration: Automatic geocoding via multi-provider service</li> <li>Cache hit optimization: Reduce external API calls</li> <li>Administrative data: City and province extraction</li> <li>Representative lookup: Fast postal code \u2192 representative mapping</li> </ul> <p>Use Cases:</p> <ul> <li>Campaign postal code lookups</li> <li>Geographic representative mapping</li> <li>Postal code validation</li> <li>Centroid-based spatial queries</li> </ul>"},{"location":"v2/features/influence/postal-codes/#architecture","title":"Architecture","text":"<pre><code>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</code></pre>"},{"location":"v2/features/influence/postal-codes/#database-models","title":"Database Models","text":""},{"location":"v2/features/influence/postal-codes/#postalcodecache-model","title":"PostalCodeCache Model","text":"<p>See PostalCodeCache Model Documentation for full schema.</p> <p>Key Fields:</p> Field Type Description <code>postalCode</code> String Normalized postal code (primary key) <code>latitude</code> Float Centroid latitude <code>longitude</code> Float Centroid longitude <code>city</code> String? City name <code>province</code> String? Province abbreviation <p>Indexes:</p> <ul> <li><code>postalCode</code> \u2014 Primary key, unique constraint</li> </ul> <p>Related Models:</p> <ul> <li>Representative \u2014 Uses postal codes for caching</li> <li>Location \u2014 Uses postal codes for geocoding</li> </ul>"},{"location":"v2/features/influence/postal-codes/#api-endpoints","title":"API Endpoints","text":""},{"location":"v2/features/influence/postal-codes/#admin-endpoints","title":"Admin Endpoints","text":"Method Endpoint Auth Description GET <code>/api/postal-codes/stats</code> SUPER_ADMIN, INFLUENCE_ADMIN Get cache statistics POST <code>/api/postal-codes/lookup</code> SUPER_ADMIN, INFLUENCE_ADMIN Manual postal code lookup"},{"location":"v2/features/influence/postal-codes/#public-endpoints","title":"Public Endpoints","text":"<p>Postal code lookups are performed automatically via representative lookup (no direct public access).</p>"},{"location":"v2/features/influence/postal-codes/#configuration","title":"Configuration","text":""},{"location":"v2/features/influence/postal-codes/#environment-variables","title":"Environment Variables","text":"Variable Type Default Description <code>GEOCODING_PROVIDER</code> string nominatim Default geocoding provider <code>GEOCODING_FALLBACK_PROVIDERS</code> 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":"<p>Steps:</p> <ol> <li>Navigate to Influence > Representatives</li> <li>View postal code cache statistics</li> <li>Monitor cache hit rate</li> </ol>"},{"location":"v2/features/influence/postal-codes/#public-workflow","title":"Public Workflow","text":"<p>Postal code caching is automatic and transparent to public users.</p>"},{"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":"<pre><code>// 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</code></pre>"},{"location":"v2/features/influence/postal-codes/#related-documentation","title":"Related Documentation","text":"<ul> <li>Representatives Module</li> <li>Geocoding Service</li> </ul>"},{"location":"v2/features/influence/representatives/","title":"Representative Lookup System","text":""},{"location":"v2/features/influence/representatives/#overview","title":"Overview","text":"<p>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.</p> <p>Key Capabilities:</p> <ul> <li>Represent API integration: Real-time lookup of elected officials by postal code</li> <li>Multi-level support: Federal, provincial, and municipal representatives</li> <li>Intelligent caching: Reduce API calls and improve performance</li> <li>Cache invalidation: Manual and automatic cache refresh</li> <li>Admin tools: Cache statistics, manual lookup, bulk operations</li> <li>Error handling: Graceful fallback for API failures</li> </ul> <p>Use Cases:</p> <ul> <li>Email-your-MP campaigns</li> <li>Multi-level government outreach</li> <li>Representative contact information lookup</li> <li>Geographic representation analysis</li> <li>Campaign targeting by electoral district</li> </ul>"},{"location":"v2/features/influence/representatives/#architecture","title":"Architecture","text":"<pre><code>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</code></pre> <p>Flow Description:</p> <ol> <li>User enters postal code \u2192 Representative service checks cache</li> <li>Cache miss \u2192 Represent API client fetches representatives</li> <li>API response \u2192 Parse representatives, save to cache</li> <li>Cache hit \u2192 Return cached representatives (skip API call)</li> <li>Admin management \u2192 View cache stats, manual lookup, clear cache</li> <li>Cache invalidation \u2192 Automatic cleanup of stale entries (>30 days)</li> </ol>"},{"location":"v2/features/influence/representatives/#database-models","title":"Database Models","text":""},{"location":"v2/features/influence/representatives/#representative-model","title":"Representative Model","text":"<p>See Representative Model Documentation for full schema.</p> <p>Key Fields:</p> Field Type Description <code>id</code> String (UUID) Primary key <code>representId</code> String Represent API unique identifier <code>name</code> String Full name of representative <code>email</code> String Email address <code>districtName</code> String Electoral district name <code>electedOffice</code> String Office held (MP, MPP, Mayor, etc.) <code>partyName</code> String? Political party affiliation <code>photoUrl</code> String? Profile photo URL <code>postalCode</code> String Associated postal code (cache key) <code>level</code> String Government level (federal, provincial, municipal) <code>lastUpdated</code> DateTime Cache timestamp <p>Indexes:</p> <ul> <li><code>postalCode, level</code> \u2014 Composite index for fast lookups</li> <li><code>representId</code> \u2014 Unique constraint</li> <li><code>lastUpdated</code> \u2014 For cache invalidation queries</li> </ul> <p>Related Models:</p> <ul> <li>Campaign \u2014 Campaigns target representatives</li> <li>CampaignEmail \u2014 Emails sent to representatives</li> </ul>"},{"location":"v2/features/influence/representatives/#api-endpoints","title":"API Endpoints","text":""},{"location":"v2/features/influence/representatives/#admin-endpoints","title":"Admin Endpoints","text":"<p>See Representatives Module API Reference for full details.</p> Method Endpoint Auth Description GET <code>/api/representatives</code> SUPER_ADMIN, INFLUENCE_ADMIN List all cached representatives GET <code>/api/representatives/stats</code> SUPER_ADMIN, INFLUENCE_ADMIN Get cache statistics POST <code>/api/representatives/lookup</code> SUPER_ADMIN, INFLUENCE_ADMIN Manual postal code lookup DELETE <code>/api/representatives/:id</code> SUPER_ADMIN, INFLUENCE_ADMIN Delete cached representative DELETE <code>/api/representatives/postal-code/:postalCode</code> SUPER_ADMIN, INFLUENCE_ADMIN Delete all reps for postal code"},{"location":"v2/features/influence/representatives/#public-endpoints","title":"Public Endpoints","text":"<p>See Representatives Module API Reference.</p> Method Endpoint Auth Description POST <code>/api/public/representatives/lookup</code> 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 <code>REPRESENT_API_URL</code> string https://represent.opennorth.ca Represent API base URL <code>REPRESENT_CACHE_TTL</code> number 2592000 Cache TTL in seconds (30 days) <code>REPRESENT_RATE_LIMIT</code> number 60 Max requests per minute"},{"location":"v2/features/influence/representatives/#represent-api","title":"Represent API","text":"<p>The Represent API is a public service provided by Open North. No API key required.</p> <p>API Documentation: https://represent.opennorth.ca/api/</p> <p>Endpoints Used:</p> <ul> <li><code>GET /postcodes/:postalCode/</code> \u2014 Lookup representatives by postal code</li> <li><code>GET /representatives/</code> \u2014 List representatives (unused, direct lookups only)</li> </ul> <p>Rate Limits:</p> <ul> <li>60 requests per minute per IP address</li> <li>Exceeding limit returns HTTP 429</li> </ul> <p>Postal Code Format:</p> <ul> <li>Canadian postal codes only</li> <li>Format: <code>K1A 0A1</code> or <code>K1A0A1</code> (space optional)</li> <li>Normalized to uppercase without spaces for API calls</li> </ul>"},{"location":"v2/features/influence/representatives/#admin-workflow","title":"Admin Workflow","text":""},{"location":"v2/features/influence/representatives/#1-view-cache-statistics","title":"1. View Cache Statistics","text":"<p>[Screenshot: RepresentativesPage with cache stats cards]</p> <p>Steps:</p> <ol> <li>Navigate to Influence > Representatives</li> <li>View cache statistics:</li> <li>Total Cached: Total representatives in cache</li> <li>Unique Postal Codes: Number of postal codes cached</li> <li>Cache Hit Rate: Percentage of lookups served from cache</li> <li>Stale Entries: Entries older than 30 days</li> </ol> <p>Code Example (RepresentativesPage.tsx):</p> <pre><code>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</code></pre>"},{"location":"v2/features/influence/representatives/#2-manual-postal-code-lookup","title":"2. Manual Postal Code Lookup","text":"<p>[Screenshot: RepresentativesPage with postal code search form]</p> <p>Steps:</p> <ol> <li>Enter postal code in search box (e.g., \"K1A 0A1\")</li> <li>Click Lookup button</li> <li>View results:</li> <li>Representative name, office, party</li> <li>Electoral district</li> <li>Email address (if available)</li> <li>Results automatically cached for future lookups</li> </ol> <p>Use Cases:</p> <ul> <li>Pre-populate cache for campaign areas</li> <li>Verify representative information</li> <li>Test postal code validation</li> <li>Troubleshoot lookup issues</li> </ul> <p>Code Example (representatives.service.ts):</p> <pre><code>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</code></pre>"},{"location":"v2/features/influence/representatives/#3-clear-stale-cache-entries","title":"3. Clear Stale Cache Entries","text":"<p>[Screenshot: RepresentativesPage with \"Clear Stale Cache\" button]</p> <p>Steps:</p> <ol> <li>Click Clear Stale Cache button</li> <li>Confirm deletion in modal</li> <li>System deletes all entries older than 30 days</li> <li>View updated cache statistics</li> </ol> <p>Automatic Cleanup:</p> <p>Cache invalidation also runs automatically via cron job (daily at 2 AM):</p> <pre><code>// 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</code></pre>"},{"location":"v2/features/influence/representatives/#4-delete-specific-cache-entries","title":"4. Delete Specific Cache Entries","text":"<p>[Screenshot: RepresentativesPage table with delete buttons]</p> <p>Steps:</p> <ol> <li>Browse cached representatives table</li> <li>Click Delete button on specific row</li> <li>Confirm deletion</li> <li>Representative removed from cache (will be re-fetched on next lookup)</li> </ol> <p>Bulk Delete by Postal Code:</p> <ol> <li>Click Delete All button on postal code group</li> <li>Confirm deletion</li> <li>All representatives for that postal code removed from cache</li> </ol>"},{"location":"v2/features/influence/representatives/#public-workflow","title":"Public Workflow","text":""},{"location":"v2/features/influence/representatives/#1-enter-postal-code","title":"1. Enter Postal Code","text":"<p>[Screenshot: CampaignPage with postal code input field]</p> <p>User Journey:</p> <ol> <li>User visits campaign page (<code>/campaigns/{slug}</code>)</li> <li>Enters postal code in lookup form</li> <li>Clicks Find My Representatives</li> <li>System performs lookup (cache or API)</li> <li>Representatives displayed below form</li> </ol> <p>Code Example (CampaignPage.tsx):</p> <pre><code>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</code></pre>"},{"location":"v2/features/influence/representatives/#2-view-representatives","title":"2. View Representatives","text":"<p>[Screenshot: Representative cards with contact information]</p> <p>Display Fields:</p> <ul> <li>Representative name</li> <li>Elected office (MP, MPP, Mayor, Councillor)</li> <li>Political party (if applicable)</li> <li>Electoral district name</li> <li>Photo (if available)</li> <li>Email button (if email available)</li> </ul> <p>Filtering:</p> <p>Representatives filtered by campaign's <code>targetGovernmentLevels</code>:</p> <pre><code>// Filter representatives by campaign levels\nconst filteredRepresentatives = representatives.filter(rep =>\n campaign.targetGovernmentLevels.includes(rep.level)\n);\n</code></pre>"},{"location":"v2/features/influence/representatives/#3-select-representatives-to-email","title":"3. Select Representatives to Email","text":"<p>[Screenshot: Representative list with checkboxes]</p> <p>User Journey:</p> <ol> <li>User reviews list of representatives</li> <li>Selects representatives to email (checkboxes)</li> <li>Clicks Continue to email form</li> <li>System pre-populates recipient list</li> </ol> <p>Code Example:</p> <pre><code>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</code></pre>"},{"location":"v2/features/influence/representatives/#volunteer-workflow","title":"Volunteer Workflow","text":"<p>Not applicable \u2014 representative lookup is public-facing and admin-managed.</p>"},{"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":"<pre><code>// 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</code></pre>"},{"location":"v2/features/influence/representatives/#frontend-representative-card-component","title":"Frontend: Representative Card Component","text":"<pre><code>// 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</code></pre>"},{"location":"v2/features/influence/representatives/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/features/influence/representatives/#no-representatives-found","title":"No Representatives Found","text":"<p>Symptoms: - Lookup returns empty array - Error: \"No representatives found for this postal code\"</p> <p>Solutions:</p> <ol> <li>Verify postal code format \u2192 Must be valid Canadian postal code</li> <li>Check Represent API status \u2192 Visit https://represent.opennorth.ca/health</li> <li>Test postal code manually \u2192 Try https://represent.opennorth.ca/postcodes/K1A0A1/</li> <li>Review API logs \u2192 Check for rate limit errors</li> </ol> <p>Debugging:</p> <pre><code># 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</code></pre>"},{"location":"v2/features/influence/representatives/#rate-limit-exceeded","title":"Rate Limit Exceeded","text":"<p>Symptoms: - HTTP 429 error - Error: \"Rate limit exceeded. Please try again later.\"</p> <p>Solutions:</p> <ol> <li>Implement exponential backoff \u2192 Retry with increasing delays</li> <li>Use cache more aggressively \u2192 Increase cache TTL to 60 days</li> <li>Batch lookups \u2192 Avoid rapid repeated lookups</li> <li>Contact Open North \u2192 Request rate limit increase if needed</li> </ol> <p>Code Fix (represent-api.client.ts):</p> <pre><code>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</code></pre>"},{"location":"v2/features/influence/representatives/#stale-representative-information","title":"Stale Representative Information","text":"<p>Symptoms: - Representative email bounces - Representative no longer in office</p> <p>Solutions:</p> <ol> <li>Clear cache for postal code \u2192 Delete and re-fetch</li> <li>Reduce cache TTL \u2192 Set <code>REPRESENT_CACHE_TTL</code> to 7 days (604800)</li> <li>Manual verification \u2192 Check official government websites</li> <li>Report to Represent API \u2192 If data is incorrect, report to Open North</li> </ol> <p>Manual Cache Clear:</p> <pre><code>// 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</code></pre>"},{"location":"v2/features/influence/representatives/#missing-email-addresses","title":"Missing Email Addresses","text":"<p>Symptoms: - Representative has no email address - Cannot send campaign email</p> <p>Solutions:</p> <ol> <li>Check Represent API data \u2192 Some reps don't provide email publicly</li> <li>Use manual email field \u2192 Allow admins to add email addresses</li> <li>Fallback to constituency office \u2192 Use office email if available</li> <li>Skip representative \u2192 Don't include in email recipients</li> </ol> <p>Code Fix (representative.service.ts):</p> <pre><code>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</code></pre>"},{"location":"v2/features/influence/representatives/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/features/influence/representatives/#cache-strategy","title":"Cache Strategy","text":"<p>TTL Configuration:</p> <ul> <li>Default: 30 days (2,592,000 seconds)</li> <li>Aggressive: 60 days for stable electoral districts</li> <li>Conservative: 7 days during election periods</li> </ul> <p>Cache Warming:</p> <p>Pre-populate cache for common postal codes:</p> <pre><code>// 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</code></pre>"},{"location":"v2/features/influence/representatives/#query-optimization","title":"Query Optimization","text":"<p>Index Usage:</p> <pre><code>-- 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</code></pre> <p>Query Pattern:</p> <pre><code>// 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</code></pre>"},{"location":"v2/features/influence/representatives/#api-rate-limiting","title":"API Rate Limiting","text":"<p>Client-Side Rate Limiter:</p> <pre><code>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</code></pre> <p>Redis-Based Distributed Rate Limiting:</p> <pre><code>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</code></pre>"},{"location":"v2/features/influence/representatives/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/features/influence/representatives/#backend-modules","title":"Backend Modules","text":"<ul> <li>Representatives Module \u2014 Full API reference</li> <li>Campaigns Module \u2014 Campaign integration</li> <li>Postal Codes Module \u2014 Postal code caching</li> </ul>"},{"location":"v2/features/influence/representatives/#frontend-pages","title":"Frontend Pages","text":"<ul> <li>RepresentativesPage \u2014 Admin cache management</li> <li>CampaignPage \u2014 Public representative lookup</li> </ul>"},{"location":"v2/features/influence/representatives/#database-models_1","title":"Database Models","text":"<ul> <li>Representative \u2014 Representative schema</li> <li>Campaign \u2014 Campaign schema</li> <li>CampaignEmail \u2014 Email tracking schema</li> </ul>"},{"location":"v2/features/influence/representatives/#external-apis","title":"External APIs","text":"<ul> <li>Represent API Documentation \u2014 Official API docs</li> <li>Open North \u2014 Represent API provider</li> </ul>"},{"location":"v2/features/influence/representatives/#configuration_1","title":"Configuration","text":"<ul> <li>Environment Variables \u2014 Represent API settings</li> </ul>"},{"location":"v2/features/influence/responses/","title":"Response Wall System","text":""},{"location":"v2/features/influence/responses/#overview","title":"Overview","text":"<p>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.</p> <p>Key Capabilities:</p> <ul> <li>Multiple response types: EMAIL, LETTER, PHONE_CALL, MEETING, SOCIAL_MEDIA, OTHER</li> <li>Email verification: Prevent spam with email confirmation</li> <li>Admin moderation: PENDING \u2192 APPROVED/REJECTED workflow</li> <li>Upvoting system: Community engagement with IP + user tracking</li> <li>Screenshot uploads: Visual proof of participation</li> <li>Public response wall: SEO-friendly public display</li> <li>Moderation dashboard: Admin tools for reviewing submissions</li> </ul> <p>Use Cases:</p> <ul> <li>Public display of campaign participation</li> <li>Social proof for advocacy campaigns</li> <li>Community engagement and sharing</li> <li>Response verification and moderation</li> <li>Campaign effectiveness metrics</li> </ul>"},{"location":"v2/features/influence/responses/#architecture","title":"Architecture","text":"<pre><code>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</code></pre> <p>Flow Description:</p> <ol> <li>User submits response \u2192 Response service saves with PENDING status</li> <li>Verification email sent \u2192 User clicks link to verify email</li> <li>Email verified \u2192 Response marked as email verified</li> <li>Admin reviews \u2192 Moderates response (approve/reject)</li> <li>Response approved \u2192 Appears on public response wall</li> <li>Users upvote \u2192 Upvote service tracks votes, increments count</li> <li>Public views wall \u2192 Only approved responses displayed</li> </ol>"},{"location":"v2/features/influence/responses/#database-models","title":"Database Models","text":""},{"location":"v2/features/influence/responses/#response-model","title":"Response Model","text":"<p>See Response Model Documentation for full schema.</p> <p>Key Fields:</p> Field Type Description <code>id</code> String (UUID) Primary key <code>campaignId</code> String Associated campaign <code>responseType</code> Enum EMAIL, LETTER, PHONE_CALL, MEETING, SOCIAL_MEDIA, OTHER <code>message</code> String User's response message <code>screenshotUrl</code> String? Uploaded screenshot URL <code>name</code> String Submitter's name <code>email</code> String Submitter's email <code>postalCode</code> String? Submitter's postal code <code>isEmailVerified</code> Boolean Email verification status <code>status</code> Enum PENDING, APPROVED, REJECTED <code>upvotes</code> Int Number of upvotes <code>moderatedByUserId</code> String? Admin who moderated <code>moderationNotes</code> String? Admin notes <p>Indexes:</p> <ul> <li><code>campaignId, status</code> \u2014 For public wall queries</li> <li><code>email, campaignId</code> \u2014 Prevent duplicate submissions</li> <li><code>isEmailVerified</code> \u2014 Filter unverified responses</li> </ul>"},{"location":"v2/features/influence/responses/#responseupvote-model","title":"ResponseUpvote Model","text":"<p>See ResponseUpvote Model Documentation for full schema.</p> <p>Key Fields:</p> Field Type Description <code>id</code> String (UUID) Primary key <code>responseId</code> String Associated response <code>ipAddress</code> String? Voter IP address <code>userId</code> String? Voter user ID (if logged in) <p>Constraints:</p> <ul> <li>Unique constraint on <code>responseId, ipAddress</code> \u2014 Prevent duplicate upvotes by IP</li> <li>Unique constraint on <code>responseId, userId</code> \u2014 Prevent duplicate upvotes by user</li> </ul> <p>Related Models:</p> <ul> <li>Campaign \u2014 Campaign association</li> <li>User \u2014 Moderation user</li> </ul>"},{"location":"v2/features/influence/responses/#api-endpoints","title":"API Endpoints","text":""},{"location":"v2/features/influence/responses/#admin-endpoints","title":"Admin Endpoints","text":"<p>See Responses Module API Reference for full details.</p> Method Endpoint Auth Description GET <code>/api/responses</code> SUPER_ADMIN, INFLUENCE_ADMIN List all responses (paginated, filterable) GET <code>/api/responses/:id</code> SUPER_ADMIN, INFLUENCE_ADMIN Get response details PATCH <code>/api/responses/:id/moderate</code> SUPER_ADMIN, INFLUENCE_ADMIN Approve/reject response DELETE <code>/api/responses/:id</code> SUPER_ADMIN Delete response"},{"location":"v2/features/influence/responses/#public-endpoints","title":"Public Endpoints","text":"<p>See Responses Module API Reference.</p> Method Endpoint Auth Description GET <code>/api/public/responses/:campaignId</code> None List approved responses for campaign POST <code>/api/public/responses</code> None Submit new response GET <code>/api/public/responses/verify/:token</code> None Verify email via token POST <code>/api/public/responses/:id/upvote</code> 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 <code>EMAIL_TEST_MODE</code> boolean false Send verification emails to MailHog <code>SMTP_FROM_EMAIL</code> string - Sender email for verification <code>SMTP_FROM_NAME</code> string - Sender name for verification <code>RESPONSE_VERIFICATION_URL</code> string - Base URL for verification links"},{"location":"v2/features/influence/responses/#campaign-feature-flags","title":"Campaign Feature Flags","text":"<p>Response wall behavior configured per campaign:</p> Flag Description <code>showResponseWall</code> Enable response wall for campaign <code>requireEmailVerification</code> Require email verification before display <code>allowAnonymousResponses</code> Allow submissions without login"},{"location":"v2/features/influence/responses/#upload-configuration","title":"Upload Configuration","text":"<p>Screenshots uploaded to <code>/uploads/responses/{responseId}/{filename}</code>.</p> <p>Limits: - Max file size: 5MB - Allowed formats: jpg, jpeg, png, gif, webp</p>"},{"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":"<p>[Screenshot: ResponsesPage with pending filter active]</p> <p>Steps:</p> <ol> <li>Navigate to Influence > Responses</li> <li>Click Pending filter tab</li> <li>View pending responses requiring moderation</li> <li>Sort by submission date (newest first)</li> </ol> <p>Code Example (ResponsesPage.tsx):</p> <pre><code>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</code></pre>"},{"location":"v2/features/influence/responses/#2-review-response-details","title":"2. Review Response Details","text":"<p>[Screenshot: Response detail drawer with full content]</p> <p>Steps:</p> <ol> <li>Click View on response row</li> <li>Review response details:</li> <li>Campaign name</li> <li>Response type</li> <li>Submitter name and email</li> <li>Message content</li> <li>Screenshot (if uploaded)</li> <li>Email verification status</li> <li>Submission date</li> <li>Check for spam/inappropriate content</li> </ol> <p>Moderation Checklist:</p> <ul> <li>\u2713 Message is genuine and relevant</li> <li>\u2713 Screenshot matches claimed action (if provided)</li> <li>\u2713 Email verified (if required by campaign)</li> <li>\u2713 No profanity or inappropriate content</li> <li>\u2713 Not duplicate submission</li> </ul>"},{"location":"v2/features/influence/responses/#3-approve-or-reject-response","title":"3. Approve or Reject Response","text":"<p>[Screenshot: Response detail drawer with approve/reject buttons]</p> <p>Steps:</p> <ol> <li>Click Approve or Reject button</li> <li>Add moderation notes (optional but recommended)</li> <li>Confirm action</li> <li>Response status updated</li> <li>If approved \u2192 appears on public response wall</li> <li>If rejected \u2192 hidden from public, admin can view</li> </ol> <p>Code Example (responses.service.ts):</p> <pre><code>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</code></pre>"},{"location":"v2/features/influence/responses/#4-bulk-moderation-actions","title":"4. Bulk Moderation Actions","text":"<p>[Screenshot: ResponsesPage with bulk action toolbar]</p> <p>Steps:</p> <ol> <li>Select multiple responses (checkboxes)</li> <li>Click Bulk Actions dropdown</li> <li>Choose action:</li> <li>Approve selected</li> <li>Reject selected</li> <li>Delete selected</li> <li>Confirm bulk action</li> <li>All selected responses updated</li> </ol> <p>Code Example (ResponsesPage.tsx):</p> <pre><code>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</code></pre>"},{"location":"v2/features/influence/responses/#public-workflow","title":"Public Workflow","text":""},{"location":"v2/features/influence/responses/#1-submit-response","title":"1. Submit Response","text":"<p>[Screenshot: Response submission form on ResponseWallPage]</p> <p>User Journey:</p> <ol> <li>User completes campaign action (sends email)</li> <li>Clicks Share Your Response link</li> <li>Navigated to <code>/responses/{campaignId}/submit</code></li> <li>Fills in response form:</li> <li>Response type (dropdown)</li> <li>Name</li> <li>Email</li> <li>Postal code (optional)</li> <li>Message (what they did)</li> <li>Screenshot (optional upload)</li> <li>Clicks Submit Response</li> <li>System saves response as PENDING</li> <li>Verification email sent (if required)</li> </ol> <p>Code Example (ResponseWallPage.tsx):</p> <pre><code>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</code></pre>"},{"location":"v2/features/influence/responses/#2-verify-email","title":"2. Verify Email","text":"<p>[Screenshot: Email verification success page]</p> <p>User Journey:</p> <ol> <li>User receives verification email</li> <li>Clicks verification link</li> <li>Navigated to <code>/api/public/responses/verify/{token}</code></li> <li>System verifies email</li> <li>Response marked as email verified</li> <li>User redirected to response wall</li> <li>Message: \"Email verified! Your response will appear after admin approval.\"</li> </ol> <p>Verification Email Template:</p> <pre><code><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</code></pre>"},{"location":"v2/features/influence/responses/#3-view-response-wall","title":"3. View Response Wall","text":"<p>[Screenshot: Public response wall with approved responses]</p> <p>User Journey:</p> <ol> <li>User visits <code>/responses/{campaignId}</code></li> <li>Sees approved responses</li> <li>Responses sorted by upvotes (most upvoted first)</li> <li>Can upvote responses</li> <li>Can filter by response type</li> </ol> <p>Code Example (ResponseWallPage.tsx):</p> <pre><code>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</code></pre>"},{"location":"v2/features/influence/responses/#4-upvote-response","title":"4. Upvote Response","text":"<p>[Screenshot: Response card with upvote button]</p> <p>User Journey:</p> <ol> <li>User clicks upvote button on response</li> <li>System checks for existing upvote (IP + user)</li> <li>If first upvote \u2192 increment count, save upvote record</li> <li>If already upvoted \u2192 show message \"You already upvoted this\"</li> <li>Upvote count updated in real-time</li> </ol> <p>Code Example (responses-public.routes.ts):</p> <pre><code>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</code></pre>"},{"location":"v2/features/influence/responses/#volunteer-workflow","title":"Volunteer Workflow","text":"<p>Not applicable \u2014 response wall is public-facing.</p>"},{"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":"<pre><code>// 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</code></pre>"},{"location":"v2/features/influence/responses/#frontend-response-card-component","title":"Frontend: Response Card Component","text":"<pre><code>// 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</code></pre>"},{"location":"v2/features/influence/responses/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/features/influence/responses/#verification-email-not-received","title":"Verification Email Not Received","text":"<p>Symptoms: - User doesn't receive verification email - Email not in spam folder</p> <p>Solutions:</p> <ol> <li>Check email service logs \u2192 <code>docker compose logs api | grep \"verification\"</code></li> <li>Verify SMTP configuration \u2192 test with <code>/api/auth/test-email</code></li> <li>Check EMAIL_TEST_MODE \u2192 if true, email sent to MailHog (localhost:8025)</li> <li>Resend verification email \u2192 manual resend via admin UI</li> </ol> <p>Manual Resend:</p> <pre><code>// Admin UI: ResponsesPage\nconst handleResendVerification = async (responseId: string) => {\n await api.post(`/responses/${responseId}/resend-verification`);\n message.success('Verification email resent');\n};\n</code></pre>"},{"location":"v2/features/influence/responses/#duplicate-upvotes","title":"Duplicate Upvotes","text":"<p>Symptoms: - User can upvote same response multiple times - Upvote count inflated</p> <p>Solutions:</p> <ol> <li>Check database constraints \u2192 should have unique constraint on <code>responseId, ipAddress</code></li> <li>Verify transaction \u2192 upvote creation and count increment must be atomic</li> <li>Check IP address extraction \u2192 ensure <code>req.ip</code> is correct (consider X-Forwarded-For)</li> </ol> <p>Database Fix:</p> <pre><code>-- 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</code></pre>"},{"location":"v2/features/influence/responses/#screenshot-upload-fails","title":"Screenshot Upload Fails","text":"<p>Symptoms: - Upload spinner never completes - Error: \"File too large\"</p> <p>Solutions:</p> <ol> <li>Check file size \u2192 max 5MB</li> <li>Verify file format \u2192 must be image (jpg/jpeg/png/gif/webp)</li> <li>Check upload directory permissions \u2192 <code>/uploads/responses</code> must be writable</li> <li>Increase Nginx upload limit \u2192 <code>client_max_body_size 10M;</code></li> </ol> <p>Code Fix (responses.service.ts):</p> <pre><code>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</code></pre>"},{"location":"v2/features/influence/responses/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/features/influence/responses/#query-optimization","title":"Query Optimization","text":"<p>Index Strategy:</p> <pre><code>-- 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</code></pre> <p>Optimized Public Query:</p> <pre><code>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</code></pre>"},{"location":"v2/features/influence/responses/#caching-strategy","title":"Caching Strategy","text":"<p>Redis Caching for Response Wall:</p> <pre><code>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</code></pre>"},{"location":"v2/features/influence/responses/#screenshot-optimization","title":"Screenshot Optimization","text":"<p>Image Processing Pipeline:</p> <pre><code>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</code></pre> <p>CDN Integration:</p> <pre><code>// 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</code></pre>"},{"location":"v2/features/influence/responses/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/features/influence/responses/#backend-modules","title":"Backend Modules","text":"<ul> <li>Responses Module \u2014 Full API reference</li> <li>Campaigns Module \u2014 Campaign integration</li> <li>Email Service \u2014 Email verification</li> </ul>"},{"location":"v2/features/influence/responses/#frontend-pages","title":"Frontend Pages","text":"<ul> <li>ResponsesPage \u2014 Admin moderation</li> <li>ResponseWallPage \u2014 Public response wall</li> </ul>"},{"location":"v2/features/influence/responses/#database-models_1","title":"Database Models","text":"<ul> <li>Response \u2014 Response schema</li> <li>ResponseUpvote \u2014 Upvote tracking schema</li> <li>Campaign \u2014 Campaign schema</li> </ul>"},{"location":"v2/features/influence/responses/#guides","title":"Guides","text":"<ul> <li>Campaign Management \u2014 Campaign configuration</li> <li>Email Templates \u2014 Verification email templates</li> </ul>"},{"location":"v2/features/landing-pages/","title":"Landing Pages (Page Builder)","text":"<p>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.</p>"},{"location":"v2/features/landing-pages/#overview","title":"Overview","text":"<p>The Landing Pages system consists of four integrated components:</p> <ol> <li>Page Builder - Page CRUD and management</li> <li>GrapesJS Editor - WYSIWYG editor</li> <li>Block Library - Reusable content blocks</li> <li>MkDocs Export - Export to Jinja2 templates</li> </ol>"},{"location":"v2/features/landing-pages/#features","title":"Features","text":""},{"location":"v2/features/landing-pages/#wysiwyg-editor","title":"WYSIWYG Editor","text":"<ul> <li>GrapesJS integration</li> <li>Drag-and-drop interface</li> <li>Visual editing</li> <li>Component customization</li> <li>CSS styling</li> <li>Responsive preview</li> <li>Desktop-only (mobile warning)</li> </ul>"},{"location":"v2/features/landing-pages/#block-library","title":"Block Library","text":"<p>Pre-built components:</p> <ul> <li>Hero sections - Large header with CTA</li> <li>Feature grids - Multi-column features</li> <li>Call-to-action - Button sections</li> <li>Text blocks - Rich text content</li> <li>Image galleries - Photo grids</li> <li>Forms - Contact forms (future)</li> <li>Custom HTML - Raw HTML blocks</li> </ul>"},{"location":"v2/features/landing-pages/#page-management","title":"Page Management","text":"<ul> <li>Create/edit/delete pages</li> <li>Slug management (URL-friendly)</li> <li>Meta tags (title, description)</li> <li>Published/draft status</li> <li>Page settings (layout, scripts)</li> <li>Search and filtering</li> </ul>"},{"location":"v2/features/landing-pages/#mkdocs-export","title":"MkDocs Export","text":"<ul> <li>Export to Jinja2 Material theme templates</li> <li>Custom overrides directory</li> <li>Static page generation</li> <li>SEO optimization</li> <li>Template inheritance</li> </ul>"},{"location":"v2/features/landing-pages/#user-flow","title":"User Flow","text":""},{"location":"v2/features/landing-pages/#admin-experience","title":"Admin Experience","text":"<ol> <li>Create Page (<code>/app/pages</code>)</li> <li>Click \"New Page\"</li> <li>Enter title and slug</li> <li>Set meta description</li> <li> <p>Save draft</p> </li> <li> <p>Edit Page (<code>/app/pages/:id/edit</code>)</p> </li> <li>Full-screen GrapesJS editor</li> <li>Drag blocks from sidebar</li> <li>Customize components</li> <li>Ctrl+S to save</li> <li> <p>Preview changes</p> </li> <li> <p>Publish Page</p> </li> <li>Set status to \"Published\"</li> <li>Page appears at <code>/p/:slug</code></li> <li> <p>Listed in page table</p> </li> <li> <p>Export to MkDocs (<code>/app/services/docs</code>)</p> </li> <li>Select pages to export</li> <li>Click \"Export\"</li> <li>Pages saved to MkDocs overrides</li> <li>Rebuild MkDocs site</li> </ol>"},{"location":"v2/features/landing-pages/#public-experience","title":"Public Experience","text":"<ol> <li>View Landing Page (<code>/p/:slug</code>)</li> <li>Rendered HTML/CSS</li> <li>Custom styling</li> <li>Responsive design</li> <li>SEO metadata</li> </ol>"},{"location":"v2/features/landing-pages/#architecture","title":"Architecture","text":""},{"location":"v2/features/landing-pages/#backend-components","title":"Backend Components","text":"<p>Module: - <code>api/src/modules/pages/pages-admin.routes.ts</code> - Admin CRUD - <code>api/src/modules/pages/pages-public.routes.ts</code> - Public renderer - <code>api/src/modules/pages/blocks.routes.ts</code> - Block library API - <code>api/src/modules/pages/pages.service.ts</code> - Business logic - <code>api/src/modules/pages/pages.schemas.ts</code> - Zod validation</p> <p>Database Models: - <code>Page</code> - Page definitions (title, slug, html, css, settings) - <code>PageBlock</code> - Reusable block library</p>"},{"location":"v2/features/landing-pages/#frontend-components","title":"Frontend Components","text":"<p>Admin Pages: - <code>admin/src/pages/LandingPagesPage.tsx</code> - Page management table - <code>admin/src/pages/PageEditorPage.tsx</code> - Full-screen editor</p> <p>Public Pages: - <code>admin/src/pages/public/LandingPage.tsx</code> - Page renderer</p> <p>Editor Component: - <code>admin/src/components/GrapesJSEditor.tsx</code> - GrapesJS wrapper</p>"},{"location":"v2/features/landing-pages/#configuration","title":"Configuration","text":""},{"location":"v2/features/landing-pages/#environment-variables","title":"Environment Variables","text":"<pre><code># MkDocs export directory (inside Docker)\nMKDOCS_EXPORT_DIR=/mkdocs/docs/overrides\n</code></pre>"},{"location":"v2/features/landing-pages/#page-settings","title":"Page Settings","text":"<p>Each page can configure:</p> <ul> <li>Meta title - Browser title tag</li> <li>Meta description - SEO description</li> <li>Custom CSS - Page-specific styles</li> <li>Custom JS - Page-specific scripts</li> <li>Layout - Template wrapper (future)</li> </ul>"},{"location":"v2/features/landing-pages/#grapesjs-integration","title":"GrapesJS Integration","text":""},{"location":"v2/features/landing-pages/#editor-setup","title":"Editor Setup","text":"<pre><code>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</code></pre>"},{"location":"v2/features/landing-pages/#custom-blocks","title":"Custom Blocks","text":"<p>Blocks defined in <code>GrapesJSEditor.tsx</code>:</p> <pre><code>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</code></pre>"},{"location":"v2/features/landing-pages/#save-handler","title":"Save Handler","text":"<p>Ctrl+S keyboard shortcut:</p> <pre><code>editor.on('run:core:save', () => {\n const html = editor.getHtml();\n const css = editor.getCss();\n onSave({ html, css });\n});\n</code></pre>"},{"location":"v2/features/landing-pages/#mkdocs-export_1","title":"MkDocs Export","text":""},{"location":"v2/features/landing-pages/#export-process","title":"Export Process","text":"<ol> <li>Select Pages - Admin selects pages to export</li> <li>Generate Jinja2 - Wrap HTML in Material theme template</li> <li>Save to Overrides - Write to <code>mkdocs/docs/overrides/</code></li> <li>Configure Front Matter - Set template, title, description</li> <li>Rebuild Site - MkDocs regenerates static site</li> </ol>"},{"location":"v2/features/landing-pages/#jinja2-template-wrapper","title":"Jinja2 Template Wrapper","text":"<pre><code>{% extends \"main.html\" %}\n\n{% block content %}\n<style>\n{{ page_css }}\n</style>\n\n{{ page_html }}\n{% endblock %}\n</code></pre>"},{"location":"v2/features/landing-pages/#front-matter","title":"Front Matter","text":"<pre><code>---\ntemplate: custom-page.html\ntitle: Page Title\ndescription: Page description for SEO\n---\n</code></pre>"},{"location":"v2/features/landing-pages/#database-schema","title":"Database Schema","text":""},{"location":"v2/features/landing-pages/#page-model","title":"Page Model","text":"<pre><code>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</code></pre>"},{"location":"v2/features/landing-pages/#pageblock-model","title":"PageBlock Model","text":"<pre><code>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</code></pre>"},{"location":"v2/features/landing-pages/#api-endpoints","title":"API Endpoints","text":""},{"location":"v2/features/landing-pages/#admin-endpoints","title":"Admin Endpoints","text":"<pre><code>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</code></pre>"},{"location":"v2/features/landing-pages/#public-endpoints","title":"Public Endpoints","text":"<pre><code>GET /api/pages/public/:slug # Get published page by slug\n</code></pre>"},{"location":"v2/features/landing-pages/#desktop-only-editor","title":"Desktop-Only Editor","text":"<p>GrapesJS editor requires desktop browser:</p> <pre><code>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</code></pre>"},{"location":"v2/features/landing-pages/#best-practices","title":"Best Practices","text":""},{"location":"v2/features/landing-pages/#slug-management","title":"Slug Management","text":"<ul> <li>Auto-generate from title</li> <li>URL-friendly (lowercase, hyphens)</li> <li>Unique constraint</li> <li>Update URL on slug change</li> </ul>"},{"location":"v2/features/landing-pages/#seo-optimization","title":"SEO Optimization","text":"<ul> <li>Meta title (50-60 chars)</li> <li>Meta description (150-160 chars)</li> <li>Semantic HTML structure</li> <li>Alt text for images</li> <li>Heading hierarchy</li> </ul>"},{"location":"v2/features/landing-pages/#performance","title":"Performance","text":"<ul> <li>Minify CSS</li> <li>Lazy load images</li> <li>Async scripts</li> <li>Cache rendered pages</li> </ul>"},{"location":"v2/features/landing-pages/#responsive-design","title":"Responsive Design","text":"<ul> <li>Mobile-first CSS</li> <li>Flexible grids</li> <li>Responsive images</li> <li>Touch-friendly buttons</li> </ul>"},{"location":"v2/features/landing-pages/#related-documentation","title":"Related Documentation","text":"<ul> <li>Page Builder</li> <li>GrapesJS Editor</li> <li>Block Library</li> <li>MkDocs Export</li> <li>Backend Pages Module</li> <li>Landing Pages Page</li> <li>Page Editor Page</li> <li>Content Editor Guide</li> </ul>"},{"location":"v2/features/map/","title":"Map Module","text":"<p>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.</p>"},{"location":"v2/features/map/#overview","title":"Overview","text":"<p>The Map module consists of ten integrated components:</p> <ol> <li>Locations - Location database with geocoding</li> <li>Geocoding - Multi-provider address \u2192 coordinate conversion</li> <li>NAR Import - Canadian electoral data import</li> <li>Cuts - Geographic polygon organization</li> <li>Shifts - Volunteer shift scheduling</li> <li>Canvassing - Door-to-door canvassing system</li> <li>Tracking - GPS tracking sessions</li> <li>Walk Sheets - Printable canvass materials</li> <li>Data Quality - Geocoding quality monitoring</li> <li>Map Features Status - Feature completion tracking</li> </ol>"},{"location":"v2/features/map/#features","title":"Features","text":""},{"location":"v2/features/map/#location-management","title":"Location Management","text":"<ul> <li>Location CRUD with address, coordinates, metadata</li> <li>CSV import/export (100,000+ records supported)</li> <li>Multi-provider geocoding (6 providers)</li> <li>Bulk geocoding with queue</li> <li>NAR 2025 server-side import (Canadian electoral data)</li> <li>Address standardization</li> <li>Visit tracking integration</li> </ul>"},{"location":"v2/features/map/#geographic-organization","title":"Geographic Organization","text":"<ul> <li>Polygon-based geographic cuts</li> <li>GeoJSON import/export</li> <li>Point-in-polygon queries</li> <li>Cut-based location assignment</li> <li>Spatial bounds calculation</li> <li>Map visualization</li> </ul>"},{"location":"v2/features/map/#volunteer-coordination","title":"Volunteer Coordination","text":"<ul> <li>Shift scheduling with cut assignment</li> <li>Volunteer signup (authenticated + anonymous)</li> <li>Email confirmations</li> <li>Temp user creation for walk-ins</li> <li>Shift capacity tracking</li> </ul>"},{"location":"v2/features/map/#canvassing-system","title":"Canvassing System","text":"<ul> <li>GPS-enabled mobile interface</li> <li>Walking route algorithm (nearest-neighbor)</li> <li>Visit outcome recording (7 outcomes)</li> <li>Session management (start/end/abandon)</li> <li>Real-time progress tracking</li> <li>Admin monitoring dashboard</li> <li>Printable walk sheets with QR codes</li> </ul>"},{"location":"v2/features/map/#map-display","title":"Map Display","text":"<ul> <li>Public interactive Leaflet map</li> <li>Color-coded markers by visit status</li> <li>Polygon overlays for cuts</li> <li>Geolocate button</li> <li>Fullscreen mode</li> <li>Legend and controls</li> </ul>"},{"location":"v2/features/map/#user-flow","title":"User Flow","text":""},{"location":"v2/features/map/#admin-experience","title":"Admin Experience","text":"<ol> <li>Import Locations (<code>/app/map/locations</code>)</li> <li>Upload CSV or NAR data</li> <li>Geocode addresses</li> <li>Review quality metrics</li> <li> <p>Bulk operations</p> </li> <li> <p>Create Cuts (<code>/app/map/cuts</code>)</p> </li> <li>Draw polygons on map</li> <li>Name and describe cut</li> <li>Assign locations (automatic)</li> <li> <p>Export for printing</p> </li> <li> <p>Schedule Shifts (<code>/app/map/shifts</code>)</p> </li> <li>Create shift with cut assignment</li> <li>Set date/time/capacity</li> <li>Email all volunteers</li> <li> <p>Monitor signups</p> </li> <li> <p>Monitor Canvassing (<code>/app/canvass/dashboard</code>)</p> </li> <li>View active sessions</li> <li>Track visit progress</li> <li>Check leaderboard</li> <li> <p>Review activity feed</p> </li> <li> <p>Print Materials (<code>/app/canvass/walk-sheet</code>)</p> </li> <li>Select cut</li> <li>Generate walk sheet PDF</li> <li>QR codes for quick access</li> <li>Browser print</li> </ol>"},{"location":"v2/features/map/#volunteer-experience","title":"Volunteer Experience","text":"<ol> <li>View Assignments (<code>/volunteer/assignments</code>)</li> <li>See upcoming shifts</li> <li>Cut information</li> <li> <p>Start canvass button</p> </li> <li> <p>Canvass (<code>/volunteer/canvass/:cutId</code>)</p> </li> <li>Full-screen map with GPS</li> <li>Follow walking route</li> <li>Click markers to record visits</li> <li>Select outcomes + notes</li> <li> <p>Track progress</p> </li> <li> <p>Review Activity (<code>/volunteer/activity</code>)</p> </li> <li>Visit history</li> <li>Outcome breakdown</li> <li>Session statistics</li> </ol>"},{"location":"v2/features/map/#public-experience","title":"Public Experience","text":"<ol> <li>View Map (<code>/map</code>)</li> <li>Browse locations</li> <li>View cuts</li> <li>See visit status (color-coded)</li> <li> <p>Geolocate self</p> </li> <li> <p>Sign Up for Shifts (<code>/shifts</code>)</p> </li> <li>Browse available shifts</li> <li>Signup with email</li> <li>Receive confirmation</li> </ol>"},{"location":"v2/features/map/#architecture","title":"Architecture","text":""},{"location":"v2/features/map/#backend-components","title":"Backend Components","text":"<p>Modules: - <code>api/src/modules/map/locations/</code> - Location CRUD + geocoding + NAR import - <code>api/src/modules/map/geocoding/</code> - Multi-provider geocoding service - <code>api/src/modules/map/cuts/</code> - Polygon CRUD + spatial queries - <code>api/src/modules/map/shifts/</code> - Shift CRUD + signups - <code>api/src/modules/map/canvass/</code> - Session + visit tracking - <code>api/src/modules/map/tracking/</code> - GPS tracking (future) - <code>api/src/modules/map/settings/</code> - Map settings singleton</p> <p>Services: - <code>api/src/services/geocoding.service.ts</code> - Geocoding abstraction - <code>api/src/services/geocode-queue.service.ts</code> - Async geocoding</p> <p>Utilities: - <code>api/src/utils/spatial.ts</code> - Point-in-polygon, haversine, bounds, centroid</p> <p>Database Models: - <code>Location</code> - Address, coordinates, metadata, visit tracking - <code>Cut</code> - Name, GeoJSON polygon - <code>Shift</code> - Date/time, cut, capacity, signups - <code>CanvassSession</code> - Session tracking, start/end times - <code>CanvassVisit</code> - Visit outcomes, notes, GPS - <code>MapSettings</code> - Map center/zoom, walk sheet config</p>"},{"location":"v2/features/map/#frontend-components","title":"Frontend Components","text":"<p>Admin Pages: - <code>admin/src/pages/LocationsPage.tsx</code> - Location management - <code>admin/src/pages/CutsPage.tsx</code> - Cut management - <code>admin/src/pages/ShiftsPage.tsx</code> - Shift management - <code>admin/src/pages/CanvassDashboardPage.tsx</code> - Canvass monitoring - <code>admin/src/pages/WalkSheetPage.tsx</code> - Printable materials - <code>admin/src/pages/DataQualityDashboardPage.tsx</code> - Quality metrics</p> <p>Public Pages: - <code>admin/src/pages/public/MapPage.tsx</code> - Public map - <code>admin/src/pages/public/ShiftsPage.tsx</code> - Shift signup</p> <p>Volunteer Pages: - <code>admin/src/pages/volunteer/VolunteerMapPage.tsx</code> - GPS canvass map - <code>admin/src/pages/volunteer/VolunteerShiftsPage.tsx</code> - Assignments - <code>admin/src/pages/volunteer/MyActivityPage.tsx</code> - Activity history</p> <p>Map Components: - <code>admin/src/components/map/MapControls.tsx</code> - Control buttons - <code>admin/src/components/map/AddLocationMode.tsx</code> - Click-to-add - <code>admin/src/components/map/CutDrawingMode.tsx</code> - Polygon drawing - <code>admin/src/components/map/CutOverlays.tsx</code> - GeoJSON rendering</p> <p>Canvass Components: - <code>admin/src/components/canvass/GPSTracker.tsx</code> - GPS tracking - <code>admin/src/components/canvass/WalkingRouteLine.tsx</code> - Route display - <code>admin/src/components/canvass/VisitRecordingForm.tsx</code> - Outcome form</p>"},{"location":"v2/features/map/#configuration","title":"Configuration","text":""},{"location":"v2/features/map/#environment-variables","title":"Environment Variables","text":"<pre><code># 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</code></pre>"},{"location":"v2/features/map/#map-settings","title":"Map Settings","text":"<p>Configurable via admin UI (<code>/app/map/settings</code>): - Default map center (lat/lng) - Default zoom level - Walk sheet header/footer - Display preferences</p>"},{"location":"v2/features/map/#geocoding","title":"Geocoding","text":""},{"location":"v2/features/map/#supported-providers","title":"Supported Providers","text":"<ol> <li>Nominatim (OpenStreetMap) - Free, rate limited</li> <li>ArcGIS - Free tier available</li> <li>Photon - Free, self-hosted option</li> <li>Mapbox - API key required</li> <li>Google Geocoding - API key required</li> <li>Pelias - Self-hosted option</li> </ol>"},{"location":"v2/features/map/#geocoding-strategy","title":"Geocoding Strategy","text":"<ol> <li>Try provider 1 (Nominatim)</li> <li>If fails, try provider 2 (ArcGIS)</li> <li>Continue through providers</li> <li>Cache successful results</li> <li>Track quality metrics</li> </ol>"},{"location":"v2/features/map/#bulk-geocoding","title":"Bulk Geocoding","text":"<ul> <li>BullMQ queue for async processing</li> <li>Batch processing (100 locations/batch)</li> <li>Provider rotation to avoid rate limits</li> <li>Progress tracking</li> <li>Error handling and retry</li> </ul>"},{"location":"v2/features/map/#nar-import","title":"NAR Import","text":"<p>Canadian electoral data (NAR 2025 format):</p> <ul> <li>Address files - Civic addresses with coordinates (EPSG:3347)</li> <li>Location files - Building locations with lat/lng</li> <li>Join on LOC_GUID - Combine address + coordinates</li> <li>Server-side streaming - Memory-efficient for large files</li> <li>Filters - Province, city, postal code, cut, residential-only</li> </ul> <p>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</p>"},{"location":"v2/features/map/#spatial-algorithms","title":"Spatial Algorithms","text":""},{"location":"v2/features/map/#point-in-polygon","title":"Point-in-Polygon","text":"<p>Ray-casting algorithm: - Count ray intersections with polygon edges - Odd count = inside, even count = outside - Supports holes in polygons - Used for cut assignment</p>"},{"location":"v2/features/map/#walking-route","title":"Walking Route","text":"<p>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</p>"},{"location":"v2/features/map/#haversine-distance","title":"Haversine Distance","text":"<p>Great-circle distance between coordinates: - Returns distance in kilometers - Used for proximity sorting - Route optimization</p>"},{"location":"v2/features/map/#api-endpoints","title":"API Endpoints","text":""},{"location":"v2/features/map/#locations","title":"Locations","text":"<pre><code>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</code></pre>"},{"location":"v2/features/map/#cuts","title":"Cuts","text":"<pre><code>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</code></pre>"},{"location":"v2/features/map/#shifts","title":"Shifts","text":"<pre><code>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</code></pre>"},{"location":"v2/features/map/#canvassing","title":"Canvassing","text":"<pre><code>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</code></pre>"},{"location":"v2/features/map/#related-documentation","title":"Related Documentation","text":"<ul> <li>Locations</li> <li>Geocoding</li> <li>NAR Import</li> <li>Cuts</li> <li>Shifts</li> <li>Canvassing</li> <li>Walk Sheets</li> <li>Data Quality</li> <li>Backend Locations Module</li> <li>Backend Canvass Module</li> <li>Spatial Utilities</li> <li>Map Organizer Guide</li> <li>Volunteer Guide</li> </ul>"},{"location":"v2/features/map/MAP_FEATURES_STATUS/","title":"Map Features Documentation Status","text":""},{"location":"v2/features/map/MAP_FEATURES_STATUS/#completion-summary","title":"Completion Summary","text":"<p>Date: 2026-02-13 Task: Create 9 comprehensive Map feature documentation files Status: 4/9 COMPLETE (in progress)</p>"},{"location":"v2/features/map/MAP_FEATURES_STATUS/#completed-files-4053-lines","title":"Completed Files (4053 lines)","text":"<ol> <li>\u2705 locations.md (1154 lines) \u2014 Location management system</li> <li>Building + unit architecture</li> <li>NAR integration</li> <li>CSV import/export</li> <li>Geocoding integration</li> <li> <p>Multi-provider support</p> </li> <li> <p>\u2705 geocoding.md (1029 lines) \u2014 Multi-provider geocoding service</p> </li> <li>6 provider fallback chain</li> <li>Confidence scoring</li> <li>Redis caching</li> <li>BullMQ bulk processing</li> <li> <p>Provider health tracking</p> </li> <li> <p>\u2705 cuts.md (924 lines) \u2014 Geographic polygon overlays</p> </li> <li>Polygon drawing workflow</li> <li>GeoJSON storage</li> <li>Point-in-polygon ray-casting</li> <li>Cut categories</li> <li> <p>Completion tracking</p> </li> <li> <p>\u2705 shifts.md (946 lines) \u2014 Volunteer shift management</p> </li> <li>Shift scheduling</li> <li>Capacity management</li> <li>Public signup</li> <li>TEMP user creation</li> <li>Email confirmations</li> </ol>"},{"location":"v2/features/map/MAP_FEATURES_STATUS/#remaining-files-5","title":"Remaining Files (5)","text":"<ol> <li>\ud83d\udea7 canvassing.md \u2014 Canvassing session system</li> <li>Session lifecycle</li> <li>Visit recording</li> <li>Walking route algorithm</li> <li>GPS integration</li> <li> <p>Volunteer + admin workflows</p> </li> <li> <p>\ud83d\udea7 tracking.md \u2014 GPS tracking system</p> </li> <li>TrackingSession model</li> <li>TrackPoint recording</li> <li>Distance calculation</li> <li>Route visualization</li> <li> <p>Live volunteer tracking</p> </li> <li> <p>\ud83d\udea7 walk-sheets.md \u2014 Printable walk sheets + QR codes</p> </li> <li>MapSettings configuration</li> <li>QR code generation</li> <li>Walk sheet layout</li> <li>Cut export</li> <li> <p>Browser print API</p> </li> <li> <p>\ud83d\udea7 data-quality.md \u2014 Geocoding quality dashboard</p> </li> <li>Confidence metrics</li> <li>Provider success rate</li> <li>Ungeocoded locations</li> <li>Low-confidence alerts</li> <li> <p>Duplicate detection</p> </li> <li> <p>\ud83d\udea7 nar-import.md \u2014 NAR 2025 electoral data import</p> </li> <li>NAR format support</li> <li>Server-side streaming</li> <li>Address + Location join</li> <li>Lambert coordinate conversion</li> <li>Province code mapping</li> </ol>"},{"location":"v2/features/map/MAP_FEATURES_STATUS/#next-steps","title":"Next Steps","text":"<p>Continue creating remaining 5 files following the established 12-section structure:</p> <ol> <li>Overview</li> <li>Architecture (Mermaid diagram)</li> <li>Database Models</li> <li>API Endpoints</li> <li>Configuration</li> <li>Admin Workflow</li> <li>Public Workflow (if applicable)</li> <li>Volunteer Workflow (if applicable)</li> <li>Code Examples</li> <li>Troubleshooting</li> <li>Performance Considerations</li> <li>Related Documentation</li> </ol> <p>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)</p>"},{"location":"v2/features/map/canvassing/","title":"Canvassing Session System","text":""},{"location":"v2/features/map/canvassing/#overview","title":"Overview","text":"<p>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.</p> <p>Key Capabilities:</p> <ul> <li>Session Lifecycle: ACTIVE \u2192 COMPLETED \u2192 ABANDONED (auto-close after 12h)</li> <li>Walking Route Algorithm: Nearest-neighbor optimization from volunteer GPS position</li> <li>Visit Recording: 7 outcome types with support level updates</li> <li>GPS Integration: Live tracking via TrackingSession (1:1 relationship)</li> <li>Rate Limiting: 30 visits/min per IP to prevent abuse</li> <li>Progress Tracking: Cut completion percentage auto-calculated</li> <li>Admin Oversight: Active sessions dashboard, activity feed, leaderboard</li> <li>Volunteer Portal: Full-screen map with bottom-sheet visit recording</li> </ul> <p>Use Cases:</p> <ul> <li>Door-to-door canvassing for electoral campaigns</li> <li>Voter ID (identifying supporter levels)</li> <li>GOTV (Get Out The Vote) efforts</li> <li>Sign placement tracking</li> <li>Petition signature collection</li> <li>Issue surveys</li> <li>Volunteer coordination</li> </ul>"},{"location":"v2/features/map/canvassing/#architecture","title":"Architecture","text":"<pre><code>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</code></pre> <p>Flow Description:</p> <ol> <li>Volunteer starts session \u2192 Creates CanvassSession + TrackingSession, loads addresses within cut</li> <li>Calculate route \u2192 Walking route service uses nearest-neighbor from volunteer GPS position</li> <li>GPS tracking \u2192 Auto-submit points every 10s, calculate distance with haversine</li> <li>Record visit \u2192 Create CanvassVisit with outcome, update Address support level, update session progress</li> <li>End session \u2192 Mark session COMPLETED, end tracking session, calculate final stats</li> <li>Admin oversight \u2192 View active sessions, activity feed, cut progress, volunteer leaderboard</li> </ol>"},{"location":"v2/features/map/canvassing/#database-models","title":"Database Models","text":""},{"location":"v2/features/map/canvassing/#canvasssession-model","title":"CanvassSession Model","text":"<p>See CanvassSession Model Documentation for full schema.</p> <p>Key Fields:</p> <ul> <li><code>userId</code>: Foreign key to volunteer User</li> <li><code>cutId</code>: Foreign key to Cut (territory)</li> <li><code>shiftId</code>: Optional foreign key to Shift (if started from shift)</li> <li><code>status</code>: ACTIVE | COMPLETED | ABANDONED</li> <li><code>startedAt</code>: Session start timestamp</li> <li><code>endedAt</code>: Session end timestamp (null while active)</li> <li><code>totalVisits</code>: Count of CanvassVisit records</li> <li><code>completionPercentage</code>: Auto-calculated from cut progress</li> </ul> <p>Status Lifecycle:</p> <pre><code>ACTIVE (session running)\n \u2193 (volunteer ends session)\nCOMPLETED\n\nOR\n\nACTIVE (session running > 12 hours)\n \u2193 (auto-cleanup cron)\nABANDONED\n</code></pre>"},{"location":"v2/features/map/canvassing/#canvassvisit-model","title":"CanvassVisit Model","text":"<p>See CanvassVisit Model Documentation for full schema.</p> <p>Key Fields:</p> <ul> <li><code>sessionId</code>: Foreign key to CanvassSession</li> <li><code>userId</code>: Foreign key to volunteer User</li> <li><code>addressId</code>: Foreign key to Address (specific unit visited)</li> <li><code>outcome</code>: Visit result (7 types)</li> <li><code>supportLevel</code>: Updated support level (LEVEL_1-4 or null)</li> <li><code>signRequested</code>: Boolean - resident wants lawn/window sign</li> <li><code>notes</code>: Free-text canvass notes</li> <li><code>visitedAt</code>: Visit timestamp</li> <li><code>durationSeconds</code>: Time spent at door (auto-calculated)</li> </ul> <p>Visit Outcome Enum:</p> <pre><code>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</code></pre> <p>Related Models:</p> <ul> <li>TrackingSession \u2014 GPS tracking (1:1)</li> <li>Address \u2014 Updated with visit data</li> <li>Cut \u2014 Territory boundary</li> <li>Shift \u2014 Optional shift assignment</li> </ul>"},{"location":"v2/features/map/canvassing/#api-endpoints","title":"API Endpoints","text":"<p>See Canvass Backend Module Documentation for full API reference.</p> <p>Volunteer Endpoints:</p> Method Endpoint Auth Description GET <code>/api/map/canvass/volunteer/assignments</code> Any logged-in user Get shifts with cut assignments GET <code>/api/map/canvass/volunteer/stats</code> Any logged-in user Get volunteer canvass statistics GET <code>/api/map/canvass/volunteer/visits</code> Any logged-in user List own canvass visits with pagination POST <code>/api/map/canvass/sessions</code> Any logged-in user Start new canvass session PATCH <code>/api/map/canvass/sessions/:id</code> Any logged-in user Update session (end session) GET <code>/api/map/canvass/sessions/:id/addresses</code> Any logged-in user Get addresses within session cut POST <code>/api/map/canvass/sessions/:id/route</code> Any logged-in user Calculate walking route POST <code>/api/map/canvass/visits</code> Any logged-in user Record single visit POST <code>/api/map/canvass/visits/bulk</code> Any logged-in user Record multiple visits (batch) PATCH <code>/api/map/canvass/volunteer/locations/:id</code> Any logged-in user Update location from canvass <p>Admin Endpoints:</p> Method Endpoint Auth Description GET <code>/api/map/canvass/admin/activity</code> MAP_ADMIN Get recent canvass activity feed GET <code>/api/map/canvass/admin/sessions</code> MAP_ADMIN List active canvass sessions GET <code>/api/map/canvass/admin/visits</code> MAP_ADMIN List all canvass visits with filters GET <code>/api/map/canvass/admin/progress</code> MAP_ADMIN Get cut completion progress GET <code>/api/map/canvass/admin/leaderboard</code> 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 <code>CANVASS_SESSION_TIMEOUT_HOURS</code> number <code>12</code> Auto-abandon active sessions after N hours <code>CANVASS_VISIT_RATE_LIMIT</code> number <code>30</code> Max visits per minute per IP"},{"location":"v2/features/map/canvassing/#rate-limiting","title":"Rate Limiting","text":"<p>Visit Recording Rate Limit:</p> <ul> <li>Limit: 30 visits/min per IP address</li> <li>Window: 60 seconds</li> <li>Redis Prefix: <code>rl:canvass-visit:</code></li> <li>Purpose: Prevent accidental bulk submissions from GPS auto-submit bugs</li> </ul>"},{"location":"v2/features/map/canvassing/#session-auto-cleanup","title":"Session Auto-Cleanup","text":"<p>Abandoned Session Detection:</p> <p>System automatically marks sessions as ABANDONED if:</p> <ul> <li>Status: ACTIVE</li> <li>Started: >12 hours ago</li> <li>No Activity: No visits in last hour</li> </ul> <p>Cleanup Schedule:</p> <ul> <li>Startup: On API server startup (check all active sessions)</li> <li>Cron: Every hour at :00 (setInterval)</li> </ul> <pre><code>// api/src/server.ts\nsetInterval(async () => {\n await canvassService.cleanupAbandonedSessions();\n}, 60 * 60 * 1000); // 1 hour\n</code></pre>"},{"location":"v2/features/map/canvassing/#admin-workflow","title":"Admin Workflow","text":""},{"location":"v2/features/map/canvassing/#viewing-active-sessions","title":"Viewing Active Sessions","text":"<p>Step 1: Navigate to Canvass Dashboard</p> <p>Navigate to Map \u2192 Canvass Dashboard in the admin sidebar.</p> <p>![CanvassDashboardPage Screenshot Placeholder]</p> <p>Step 2: View Active Sessions</p> <p>Active Sessions card displays:</p> <ul> <li>Volunteer Name: Who is canvassing</li> <li>Cut Name: Territory being canvassed</li> <li>Start Time: When session started</li> <li>Duration: Time elapsed (live updating)</li> <li>Visits: Number of visits recorded</li> <li>Status: ACTIVE badge</li> </ul> <p>Step 3: View Session Details</p> <p>Click session row to view:</p> <ul> <li>Volunteer GPS Location: Last known position on map</li> <li>Route: Walking route polyline</li> <li>Visited Addresses: Green markers</li> <li>Unvisited Addresses: Blue markers</li> <li>Recent Visits: Last 10 visits with outcomes</li> </ul>"},{"location":"v2/features/map/canvassing/#monitoring-canvass-activity","title":"Monitoring Canvass Activity","text":"<p>Step 1: View Activity Feed</p> <p>Recent Activity section displays:</p> <ul> <li>Volunteer Name: Who recorded visit</li> <li>Address: Location visited</li> <li>Outcome: Visit result (icon + label)</li> <li>Support Level: Updated support level (color-coded)</li> <li>Time Ago: \"5 minutes ago\"</li> </ul> <p>Step 2: Filter Activity</p> <p>Use filters:</p> <ul> <li>Date Range: Last hour / day / week / month</li> <li>Outcome: Filter by specific outcome type</li> <li>Volunteer: Filter by volunteer name</li> <li>Cut: Filter by territory</li> </ul> <p>Step 3: Export Activity</p> <p>Click Export CSV to download activity feed for reporting.</p>"},{"location":"v2/features/map/canvassing/#tracking-cut-completion","title":"Tracking Cut Completion","text":"<p>Step 1: View Cut Progress</p> <p>Cut Progress card displays:</p> <ul> <li>Cut Name: Territory name</li> <li>Total Addresses: Count of addresses in cut</li> <li>Visited: Count of addresses with CanvassVisit records</li> <li>Completion: Percentage (progress bar)</li> <li>Last Activity: Time since last visit</li> </ul> <p>Step 2: View Detailed Progress</p> <p>Click cut row to view:</p> <ul> <li>Address List: All addresses in cut with visit status</li> <li>Visit Heatmap: Map showing visited (green) vs unvisited (blue)</li> <li>Outcome Breakdown: Pie chart of visit outcomes</li> <li>Volunteer Breakdown: Who visited which addresses</li> </ul>"},{"location":"v2/features/map/canvassing/#volunteer-leaderboard","title":"Volunteer Leaderboard","text":"<p>Step 1: View Leaderboard</p> <p>Leaderboard card displays:</p> <ul> <li>Rank: 1<sup>st</sup>, 2<sup>nd</sup>, 3<sup>rd</sup> place</li> <li>Volunteer Name: Volunteer name</li> <li>Total Visits: Visit count</li> <li>Doors/Hour: Efficiency metric</li> <li>Top Outcome: Most common outcome</li> </ul> <p>Step 2: Filter by Time Period</p> <p>Toggle time period:</p> <ul> <li>Today: Visits since midnight</li> <li>This Week: Visits since Monday</li> <li>This Month: Visits since 1<sup>st</sup> of month</li> <li>All Time: Total visits</li> </ul>"},{"location":"v2/features/map/canvassing/#volunteer-workflow","title":"Volunteer Workflow","text":""},{"location":"v2/features/map/canvassing/#starting-a-canvass-session","title":"Starting a Canvass Session","text":"<p>Step 1: Login</p> <p>Login at <code>/login</code> with volunteer account (or use TEMP account from shift signup).</p> <p>Step 2: View Assignments</p> <p>Navigate to Volunteer \u2192 My Assignments.</p> <p>Step 3: Select Shift</p> <p>Click Start Canvass on a shift with cut assignment.</p> <p>Step 4: Grant GPS Permission</p> <p>Browser requests geolocation permission. Click Allow.</p> <p>Step 5: Start Session</p> <p>System redirects to <code>/volunteer/canvass/:cutId</code> (full-screen map).</p> <p>System will:</p> <ol> <li>Create CanvassSession (status=ACTIVE)</li> <li>Create TrackingSession (linked 1:1)</li> <li>Load addresses within cut polygon</li> <li>Calculate walking route from current GPS position</li> <li>Start GPS auto-tracking (submit points every 10s)</li> </ol>"},{"location":"v2/features/map/canvassing/#following-walking-route","title":"Following Walking Route","text":"<p>Step 1: View Route on Map</p> <p>Map displays:</p> <ul> <li>Blue Polyline: Optimized walking route</li> <li>Blue Markers: Unvisited addresses (ordered by route)</li> <li>Green Markers: Visited addresses</li> <li>Red Marker: Current GPS position (live updating)</li> </ul> <p>Step 2: Navigate to First Address</p> <p>Follow route to nearest unvisited address. Route recalculates when you move.</p> <p>Step 3: View Address Details</p> <p>Tap marker to view:</p> <ul> <li>Address: Street address + unit number</li> <li>Resident Name: First/Last name (if available)</li> <li>Support Level: Previous support level (if available)</li> <li>Last Visit: Previous visit outcome + date (if applicable)</li> </ul>"},{"location":"v2/features/map/canvassing/#recording-a-visit","title":"Recording a Visit","text":"<p>Step 1: Knock on Door</p> <p>Approach address and knock/ring doorbell.</p> <p>Step 2: Open Visit Recording Form</p> <p>Tap Record Visit button in bottom toolbar. Bottom sheet slides up.</p> <p>Step 3: Select Outcome</p> <p>Choose visit outcome:</p> <ul> <li>Not Home: Nobody answered</li> <li>Refused: Refused to speak</li> <li>Moved: Resident moved away</li> <li>Already Voted: Already voted (GOTV campaigns)</li> <li>Spoke With: Had conversation</li> <li>Left Literature: Left campaign material</li> <li>Come Back Later: Asked to return later</li> </ul> <p>Step 4: Update Support Level (if applicable)</p> <p>For \"Spoke With\" outcome, select support level:</p> <ul> <li>Level 1 (Strong): Green badge</li> <li>Level 2 (Leaning): Yellow badge</li> <li>Level 3 (Undecided): Gray badge</li> <li>Level 4 (Opposed): Red badge</li> </ul> <p>Step 5: Sign Request (optional)</p> <p>Toggle Sign Requested if resident wants lawn/window sign.</p> <p>Step 6: Add Notes (optional)</p> <p>Enter free-text notes (e.g., \"Asked about healthcare policy\", \"Concerned about taxes\").</p> <p>Step 7: Save Visit</p> <p>Tap Save Visit. System will:</p> <ol> <li>Create CanvassVisit record with outcome + timestamp</li> <li>Update Address with new support level + sign status + notes</li> <li>Increment session.totalVisits count</li> <li>Update cut.completionPercentage</li> <li>Create LocationHistory audit record</li> <li>Submit GPS trackpoint with eventType=VISIT_RECORDED</li> <li>Update marker to green (visited)</li> <li>Recalculate walking route (exclude visited address)</li> </ol>"},{"location":"v2/features/map/canvassing/#ending-a-canvass-session","title":"Ending a Canvass Session","text":"<p>Step 1: Finish Route</p> <p>Complete visits for all addresses (or as many as possible).</p> <p>Step 2: End Session</p> <p>Tap End Session button in header.</p> <p>Step 3: Confirm</p> <p>Confirmation modal displays session summary:</p> <ul> <li>Duration: Total session time</li> <li>Visits: Number of visits recorded</li> <li>Distance: Total distance walked (from GPS tracking)</li> <li>Doors/Hour: Efficiency metric</li> </ul> <p>Step 4: Submit</p> <p>Tap End Session. System will:</p> <ol> <li>Update CanvassSession (status=COMPLETED, endedAt=now)</li> <li>End TrackingSession (isActive=false, endedAt=now)</li> <li>Calculate final stats (totalVisits, totalDistanceM)</li> <li>Redirect to <code>/volunteer/activity</code> (visit history page)</li> </ol>"},{"location":"v2/features/map/canvassing/#viewing-visit-history","title":"Viewing Visit History","text":"<p>Step 1: Navigate to My Activity</p> <p>Navigate to Volunteer \u2192 My Activity.</p> <p>Step 2: View Visit List</p> <p>Table displays:</p> <ul> <li>Address: Location visited</li> <li>Outcome: Visit result (icon + label)</li> <li>Support Level: Updated support level</li> <li>Visit Date: Formatted date/time</li> <li>Notes: Canvassing notes (truncated)</li> </ul> <p>Step 3: Filter Visits</p> <p>Use filters:</p> <ul> <li>Date Range: Last week / month / all time</li> <li>Outcome: Filter by specific outcome</li> <li>Support Level: Filter by support level</li> </ul> <p>Step 4: View Session History</p> <p>Navigate to My Routes to view:</p> <ul> <li>Session List: Past canvass sessions</li> <li>Session Map: GPS route polyline + visited markers</li> <li>Session Stats: Duration, visits, distance, doors/hour</li> </ul>"},{"location":"v2/features/map/canvassing/#code-examples","title":"Code Examples","text":""},{"location":"v2/features/map/canvassing/#start-canvass-session-backend","title":"Start Canvass Session (Backend)","text":"<pre><code>// 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</code></pre>"},{"location":"v2/features/map/canvassing/#calculate-walking-route-backend","title":"Calculate Walking Route (Backend)","text":"<pre><code>// 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</code></pre>"},{"location":"v2/features/map/canvassing/#record-visit-backend","title":"Record Visit (Backend)","text":"<pre><code>// 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</code></pre>"},{"location":"v2/features/map/canvassing/#gps-auto-tracking-frontend","title":"GPS Auto-Tracking (Frontend)","text":"<pre><code>// 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</code></pre>"},{"location":"v2/features/map/canvassing/#visit-recording-form-frontend","title":"Visit Recording Form (Frontend)","text":"<pre><code>// 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</code></pre>"},{"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":"<p>Symptoms:</p> <ul> <li>Route backtracks frequently</li> <li>Total distance much longer than expected</li> <li>Route doesn't start from volunteer GPS position</li> </ul> <p>Causes:</p> <ul> <li>Nearest-neighbor algorithm is greedy (not globally optimal)</li> <li>Starting position not provided (defaults to cut centroid)</li> <li>GPS accuracy poor (volunteer position inaccurate)</li> </ul> <p>Solutions:</p> <ol> <li>Use volunteer GPS position as start:</li> </ol> <pre><code>// Always pass volunteer GPS position to route calculation\nconst route = await calculateWalkingRoute(\n locations,\n currentLat,\n currentLng,\n cut.geojson\n);\n</code></pre> <ol> <li>Consider alternative algorithms:</li> </ol> <p>For better optimization, use 2-opt or genetic algorithms (computationally expensive):</p> <pre><code>// Install optimization library\nnpm install routing-js\n\n// Use 2-opt algorithm\nimport { twoOpt } from 'routing-js';\nconst optimized = twoOpt(locations, distanceMatrix);\n</code></pre> <ol> <li>Pre-optimize routes for shifts:</li> </ol> <p>Admin can pre-calculate optimal routes and assign to volunteers:</p> <pre><code>// 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</code></pre>"},{"location":"v2/features/map/canvassing/#issue-session-auto-abandoned-prematurely","title":"Issue: Session Auto-Abandoned Prematurely","text":"<p>Symptoms:</p> <ul> <li>Active session marked ABANDONED while volunteer still canvassing</li> <li>Session timeout after <12 hours</li> <li>Volunteer can't record visits after timeout</li> </ul> <p>Causes:</p> <ul> <li><code>CANVASS_SESSION_TIMEOUT_HOURS</code> set too low</li> <li>Volunteer paused for lunch/break (no activity for >1 hour)</li> <li>System clock drift</li> </ul> <p>Solutions:</p> <ol> <li>Increase timeout:</li> </ol> <pre><code># In .env\nCANVASS_SESSION_TIMEOUT_HOURS=24 # Was 12, increase to 24\n</code></pre> <ol> <li>Record \"heartbeat\" visits:</li> </ol> <p>Add periodic \"still active\" ping to prevent timeout:</p> <pre><code>// Volunteer app sends heartbeat every 30 minutes\nsetInterval(async () => {\n await api.post(`/map/canvass/sessions/${sessionId}/heartbeat`);\n}, 30 * 60 * 1000);\n</code></pre> <ol> <li>Allow session resumption:</li> </ol> <p>Let volunteers resume ABANDONED sessions:</p> <pre><code>// 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</code></pre>"},{"location":"v2/features/map/canvassing/#issue-gps-tracking-draining-battery","title":"Issue: GPS Tracking Draining Battery","text":"<p>Symptoms:</p> <ul> <li>Volunteer phone battery drains rapidly</li> <li>Phone overheats during canvassing</li> <li>GPS tracking fails after 2-3 hours</li> </ul> <p>Causes:</p> <ul> <li><code>enableHighAccuracy</code> uses GPS + WiFi + cellular (power-hungry)</li> <li>Watchposition submits too frequently (every second)</li> <li>Screen stays on during entire session</li> </ul> <p>Solutions:</p> <ol> <li>Reduce GPS accuracy:</li> </ol> <pre><code>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</code></pre> <ol> <li>Reduce submission frequency:</li> </ol> <pre><code>// Submit GPS points every 30s instead of 10s\nconst SUBMIT_INTERVAL_MS = 30000; // Was 10000\n</code></pre> <ol> <li>Pause tracking during breaks:</li> </ol> <p>Add \"Pause Tracking\" button to stop GPS watchPosition:</p> <pre><code>const pauseTracking = () => {\n navigator.geolocation.clearWatch(watchId);\n setTrackingPaused(true);\n};\n\nconst resumeTracking = () => {\n // Start watchPosition again\n setTrackingPaused(false);\n};\n</code></pre>"},{"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":"<p>Prevent Abuse:</p> <p>Rate limit prevents accidental bulk submissions:</p> <pre><code>// 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</code></pre> <p>Legitimate Use Cases:</p> <ul> <li>Bulk data entry: Admin can bypass rate limit for importing historical data</li> <li>Offline sync: Mobile app queues visits offline, submits when online (batch endpoint)</li> </ul>"},{"location":"v2/features/map/canvassing/#session-cleanup-performance","title":"Session Cleanup Performance","text":"<p>Efficient Abandoned Session Query:</p> <pre><code>-- 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</code></pre>"},{"location":"v2/features/map/canvassing/#cut-completion-calculation","title":"Cut Completion Calculation","text":"<p>Avoid N+1 Queries:</p> <pre><code>// 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</code></pre>"},{"location":"v2/features/map/canvassing/#related-documentation","title":"Related Documentation","text":"<p>Backend Modules:</p> <ul> <li>Canvass Backend Module \u2014 API implementation</li> <li>Walking Route Service \u2014 Route optimization</li> <li>Tracking Service \u2014 GPS tracking</li> </ul> <p>Frontend Pages:</p> <ul> <li>VolunteerMapPage \u2014 Full-screen canvass map</li> <li>CanvassDashboardPage \u2014 Admin oversight</li> <li>MyActivityPage \u2014 Visit history</li> </ul> <p>Database:</p> <ul> <li>CanvassSession Model \u2014 Session schema</li> <li>CanvassVisit Model \u2014 Visit records</li> <li>TrackingSession Model \u2014 GPS tracking</li> </ul> <p>Features:</p> <ul> <li>Cuts \u2014 Territory boundaries for canvassing</li> <li>Shifts \u2014 Shift-based canvass scheduling</li> <li>Tracking \u2014 GPS tracking system</li> <li>Locations \u2014 Address management</li> </ul>"},{"location":"v2/features/map/cuts/","title":"Geographic Polygon Overlays (Cuts)","text":""},{"location":"v2/features/map/cuts/#overview","title":"Overview","text":"<p>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.</p> <p>Key Capabilities:</p> <ul> <li>Polygon Drawing: Click-to-draw custom polygons on Leaflet maps</li> <li>GeoJSON Storage: Store complex polygons with coordinate precision</li> <li>Spatial Queries: Point-in-polygon filtering using ray-casting algorithm</li> <li>Cut Categories: CUSTOM, WARD, NEIGHBORHOOD, DISTRICT classification</li> <li>Visual Customization: Configurable colors and opacity for map overlays</li> <li>Bounds Calculation: Auto-calculate bounding box from polygon coordinates</li> <li>Completion Tracking: Track canvassing progress by cut</li> <li>Shift Assignment: Link shifts to cuts for volunteer scheduling</li> <li>Export Filtering: Generate walk sheets for specific cuts</li> </ul> <p>Use Cases:</p> <ul> <li>Electoral district mapping (wards, polling divisions)</li> <li>Canvassing zone organization</li> <li>Neighborhood targeting</li> <li>Volunteer territory assignment</li> <li>Walk sheet generation by area</li> <li>Progress tracking by geographic zone</li> <li>Multi-volunteer coordination</li> </ul>"},{"location":"v2/features/map/cuts/#architecture","title":"Architecture","text":"<pre><code>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</code></pre> <p>Flow Description:</p> <ol> <li>Admin draws cut \u2192 Click vertices on map, auto-close detection, generate GeoJSON</li> <li>Save cut \u2192 Calculate bounds from coordinates, store polygon in database</li> <li>Public map loads \u2192 Query public cuts, render as colored overlays with opacity</li> <li>Canvass session starts \u2192 Load addresses within cut polygon using ray-casting</li> <li>Shift assignment \u2192 Link shift to cut for volunteer scheduling</li> <li>Export locations \u2192 Filter by cut polygon to generate walk sheet</li> </ol>"},{"location":"v2/features/map/cuts/#database-models","title":"Database Models","text":""},{"location":"v2/features/map/cuts/#cut-model","title":"Cut Model","text":"<p>See Cut Model Documentation for full schema.</p> <p>Key Fields:</p> <ul> <li><code>name</code>: Cut display name (e.g., \"Ward 5 - Downtown\")</li> <li><code>description</code>: Free-text notes about the cut</li> <li><code>geojson</code>: Polygon coordinates in GeoJSON format (TEXT field)</li> <li><code>bounds</code>: Auto-calculated bounding box <code>{minLat, maxLat, minLng, maxLng}</code> (JSON)</li> <li><code>color</code>: Hex color for map overlay (default: <code>#3498db</code>)</li> <li><code>opacity</code>: Opacity 0.0-1.0 for map rendering (default: 0.3)</li> <li><code>category</code>: CUSTOM | WARD | NEIGHBORHOOD | DISTRICT</li> <li><code>isPublic</code>: Show on public map</li> <li><code>isOfficial</code>: Official electoral boundary (prevents accidental deletion)</li> <li><code>showLocations</code>: Show location markers within cut on map</li> <li><code>exportEnabled</code>: Allow walk sheet export for this cut</li> <li><code>assignedTo</code>: Free-text assigned volunteer/team name</li> <li><code>completionPercentage</code>: Auto-calculated canvassing progress (0-100)</li> </ul> <p>GeoJSON Format:</p> <pre><code>{\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</code></pre> <p>Bounds Format:</p> <pre><code>{\n \"minLat\": 45.4215,\n \"maxLat\": 45.4230,\n \"minLng\": -75.6980,\n \"maxLng\": -75.6950\n}\n</code></pre> <p>Related Models:</p> <ul> <li>Shift \u2014 Volunteer shifts assigned to cut</li> <li>CanvassSession \u2014 Canvassing within cut</li> <li>Location \u2014 Filtered by cut polygon</li> </ul>"},{"location":"v2/features/map/cuts/#api-endpoints","title":"API Endpoints","text":"<p>See Cuts Backend Module Documentation for full API reference.</p> <p>Admin Endpoints:</p> Method Endpoint Auth Description GET <code>/api/map/cuts</code> MAP_ADMIN List cuts with pagination, search, category filter GET <code>/api/map/cuts/stats</code> MAP_ADMIN Get cut statistics (total, by category) GET <code>/api/map/cuts/:id</code> MAP_ADMIN Get cut details POST <code>/api/map/cuts</code> MAP_ADMIN Create new cut with polygon PATCH <code>/api/map/cuts/:id</code> MAP_ADMIN Update cut DELETE <code>/api/map/cuts/:id</code> MAP_ADMIN Delete cut (blocked if <code>isOfficial=true</code>) GET <code>/api/map/cuts/:id/locations</code> MAP_ADMIN Get locations within cut polygon GET <code>/api/map/cuts/:id/progress</code> MAP_ADMIN Get canvassing progress for cut <p>Public Endpoints:</p> Method Endpoint Auth Description GET <code>/api/public/map/cuts</code> None List public cuts (isPublic=true) GET <code>/api/public/map/cuts/:id</code> 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":"<p>No specific environment variables for cuts. Uses standard database and map settings.</p>"},{"location":"v2/features/map/cuts/#cut-category-enum","title":"Cut Category Enum","text":"<pre><code>enum CutCategory {\n CUSTOM // User-defined boundary\n WARD // Municipal ward boundary\n NEIGHBORHOOD // Neighborhood association boundary\n DISTRICT // Electoral district boundary\n}\n</code></pre>"},{"location":"v2/features/map/cuts/#default-values","title":"Default Values","text":"Field Default Description <code>color</code> <code>#3498db</code> Blue color for overlay <code>opacity</code> <code>0.3</code> 30% opacity (transparent) <code>isPublic</code> <code>false</code> Hidden from public map <code>isOfficial</code> <code>false</code> Can be deleted by admin <code>showLocations</code> <code>true</code> Show location markers within cut <code>exportEnabled</code> <code>true</code> Allow walk sheet export <code>completionPercentage</code> <code>0</code> 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":"<p>Step 1: Navigate to Cuts Page</p> <p>Navigate to Map \u2192 Cuts in the admin sidebar.</p> <p>![CutsPage Screenshot Placeholder]</p> <p>Step 2: Open Drawing Tab</p> <p>Click Drawing tab to switch to map drawing mode.</p> <p>Step 3: Activate Drawing Mode</p> <p>Click Draw Cut button in the map controls. Map cursor changes to crosshair.</p> <p>Step 4: Click Vertices</p> <p>Click on the map to place polygon vertices:</p> <ul> <li>First Click: Start polygon</li> <li>Additional Clicks: Add vertices</li> <li>Auto-Close: When cursor near start point (within 10px), polygon auto-closes</li> </ul> <p>Step 5: Configure Cut</p> <p>Fill in the cut form (right sidebar):</p> <ul> <li>Name: \"Ward 5 - Downtown\"</li> <li>Description: \"Central business district and residential blocks\"</li> <li>Category: WARD</li> <li>Color: Choose from color picker (default: blue)</li> <li>Opacity: Slider 0-100 (default: 30%)</li> <li>Is Public: Toggle to show on public map</li> <li>Is Official: Toggle to prevent accidental deletion</li> </ul> <p>Step 6: Save Cut</p> <p>Click Save Cut. The system will:</p> <ol> <li>Generate GeoJSON from vertices</li> <li>Calculate bounding box</li> <li>Save to database</li> <li>Render polygon on map with configured color/opacity</li> </ol>"},{"location":"v2/features/map/cuts/#editing-a-cut","title":"Editing a Cut","text":"<p>Step 1: Select Cut</p> <p>On Table tab, click Edit button for a cut.</p> <p>Step 2: Update Fields</p> <p>Modify cut properties:</p> <ul> <li>Name/Description: Update text fields</li> <li>Color/Opacity: Adjust visual appearance</li> <li>Category: Change classification</li> <li>Public/Official: Toggle flags</li> </ul> <p>Step 3: Re-Draw Polygon (Optional)</p> <p>To change polygon shape:</p> <ol> <li>Switch to Drawing tab</li> <li>Click Edit Cut button</li> <li>Delete old vertices (click vertices to remove)</li> <li>Add new vertices</li> <li>Auto-close polygon</li> </ol> <p>Step 4: Save Changes</p> <p>Click Update to save changes. Bounds are auto-recalculated if polygon changed.</p>"},{"location":"v2/features/map/cuts/#viewing-locations-in-cut","title":"Viewing Locations in Cut","text":"<p>Step 1: Select Cut</p> <p>Click cut row in table to select.</p> <p>Step 2: Click \"View Locations\"</p> <p>Click View Locations button.</p> <p>Step 3: View Filtered Table</p> <p>System displays locations within cut polygon:</p> <ul> <li>Point-in-Polygon: Uses ray-casting algorithm to filter</li> <li>Count: Number of locations within cut</li> <li>Support Breakdown: Count by support level</li> </ul> <p>Step 4: Export Locations</p> <p>Click Export CSV to download locations for walk sheet generation.</p>"},{"location":"v2/features/map/cuts/#assigning-cut-to-shift","title":"Assigning Cut to Shift","text":"<p>Step 1: Create/Edit Shift</p> <p>On Map \u2192 Shifts page, create or edit a shift.</p> <p>Step 2: Select Cut</p> <p>In shift form, choose cut from Cut dropdown.</p> <p>Step 3: Save Shift</p> <p>Shift is now linked to cut. Volunteers will see cut name on shift details.</p>"},{"location":"v2/features/map/cuts/#tracking-cut-completion","title":"Tracking Cut Completion","text":"<p>Step 1: View Cut Progress</p> <p>On CutsPage, click Progress button for a cut.</p> <p>Step 2: View Metrics</p> <p>System displays:</p> <ul> <li>Completion Percentage: Auto-calculated from canvass visits</li> <li>Total Addresses: Count of addresses within cut</li> <li>Visited: Count of addresses with CanvassVisit records</li> <li>Outstanding: Remaining addresses to visit</li> </ul> <p>Step 3: View Canvass Activity</p> <p>Table shows recent canvass visits within cut:</p> <ul> <li>Volunteer Name: Who visited</li> <li>Visit Date: When visited</li> <li>Outcome: Visit result (SPOKE_WITH, NOT_HOME, etc.)</li> <li>Support Level: Updated support level (if applicable)</li> </ul>"},{"location":"v2/features/map/cuts/#public-workflow","title":"Public Workflow","text":"<p>Public users can view cut overlays on the interactive map.</p> <p>Step 1: Navigate to Public Map</p> <p>Visit <code>/map</code> (no authentication required).</p> <p>Step 2: Toggle Cut Overlays</p> <p>Click Cuts button in map controls to open overlay panel.</p> <p>Step 3: Select Cuts</p> <p>Check/uncheck cuts to show/hide on map:</p> <ul> <li>Color Legend: Shows cut name and color</li> <li>Opacity: Semi-transparent overlays don't obscure markers</li> <li>Multiple Cuts: Show multiple cuts simultaneously</li> </ul> <p>Step 4: View Cut Details</p> <p>Click on a cut polygon to view:</p> <ul> <li>Cut Name: Displayed in popup</li> <li>Category: Ward, Neighborhood, etc.</li> <li>Assigned To: Volunteer/team name (if configured)</li> </ul>"},{"location":"v2/features/map/cuts/#volunteer-workflow","title":"Volunteer Workflow","text":"<p>Volunteers interact with cuts via shift assignments.</p> <p>Step 1: View Assigned Shifts</p> <p>On Volunteer \u2192 My Assignments page, view shifts with cut assignments.</p> <p>Step 2: Start Canvass Session</p> <p>Click Start Canvass on a shift. Redirects to <code>/volunteer/canvass/:cutId</code>.</p> <p>Step 3: View Cut on Map</p> <p>Full-screen map shows:</p> <ul> <li>Cut Polygon: Highlighted boundary</li> <li>Locations Within Cut: Filtered to cut polygon only</li> <li>Walking Route: Optimal route through cut locations</li> </ul> <p>See Canvassing Documentation for full volunteer workflow.</p>"},{"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":"<pre><code>// 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</code></pre>"},{"location":"v2/features/map/cuts/#bounds-calculation-backend","title":"Bounds Calculation (Backend)","text":"<pre><code>// 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</code></pre>"},{"location":"v2/features/map/cuts/#point-in-polygon-filter-backend","title":"Point-in-Polygon Filter (Backend)","text":"<pre><code>// 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</code></pre>"},{"location":"v2/features/map/cuts/#ray-casting-algorithm-backend","title":"Ray-Casting Algorithm (Backend)","text":"<pre><code>// 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</code></pre>"},{"location":"v2/features/map/cuts/#cut-drawing-mode-frontend","title":"Cut Drawing Mode (Frontend)","text":"<pre><code>// 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</code></pre>"},{"location":"v2/features/map/cuts/#cut-overlays-rendering-frontend","title":"Cut Overlays Rendering (Frontend)","text":"<pre><code>// 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</code></pre>"},{"location":"v2/features/map/cuts/#convert-leaflet-polygon-to-geojson-frontend","title":"Convert Leaflet Polygon to GeoJSON (Frontend)","text":"<pre><code>// 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</code></pre>"},{"location":"v2/features/map/cuts/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/features/map/cuts/#issue-polygon-not-closing","title":"Issue: Polygon Not Closing","text":"<p>Symptoms:</p> <ul> <li>Clicking near start point doesn't auto-close polygon</li> <li>Polygon remains open after many vertices</li> <li>\"Save Cut\" button disabled</li> </ul> <p>Causes:</p> <ul> <li>Auto-close distance threshold too small</li> <li>Mouse click precision issues on mobile</li> <li>Map zoom level affecting pixel distance calculation</li> </ul> <p>Solutions:</p> <ol> <li>Increase auto-close threshold:</li> </ol> <pre><code>// 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</code></pre> <ol> <li>Manual close button:</li> </ol> <p>Add explicit \"Close Polygon\" button for mobile users:</p> <pre><code><Button onClick={() => {\n if (vertices.length >= 3) {\n onPolygonComplete(vertices);\n }\n}}>\n Close Polygon\n</Button>\n</code></pre>"},{"location":"v2/features/map/cuts/#issue-point-in-polygon-returns-wrong-results","title":"Issue: Point-in-Polygon Returns Wrong Results","text":"<p>Symptoms:</p> <ul> <li>Locations outside cut polygon included in canvass session</li> <li>Locations inside cut polygon excluded</li> <li>Export CSV missing locations</li> </ul> <p>Causes:</p> <ul> <li>Coordinate order mismatch (GeoJSON [lng, lat] vs Leaflet [lat, lng])</li> <li>Polygon not properly closed (first vertex !== last vertex)</li> <li>Ray-casting algorithm bug with edge cases</li> </ul> <p>Solutions:</p> <ol> <li>Verify coordinate order:</li> </ol> <pre><code>// 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</code></pre> <ol> <li>Verify polygon closure:</li> </ol> <pre><code>-- 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</code></pre> <ol> <li>Test with known points:</li> </ol> <pre><code># 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</code></pre>"},{"location":"v2/features/map/cuts/#issue-cut-rendering-performance-slow","title":"Issue: Cut Rendering Performance Slow","text":"<p>Symptoms:</p> <ul> <li>Map lags when rendering multiple cuts</li> <li>Browser freezes with >10 cuts visible</li> <li>Polygon rendering takes >2 seconds</li> </ul> <p>Causes:</p> <ul> <li>Too many polygon vertices (complex boundaries)</li> <li>Multiple cut overlays rendered simultaneously</li> <li>No polygon simplification</li> </ul> <p>Solutions:</p> <ol> <li>Simplify complex polygons:</li> </ol> <p>Use Turf.js simplify algorithm to reduce vertices:</p> <pre><code>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</code></pre> <ol> <li>Lazy render cuts:</li> </ol> <p>Only render cuts within current map bounds:</p> <pre><code>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</code></pre> <ol> <li>Use Canvas renderer:</li> </ol> <p>For large polygons, use Leaflet Canvas renderer instead of SVG:</p> <pre><code><Polygon\n positions={positions}\n renderer={L.canvas()}\n pathOptions={{ color: cut.color }}\n/>\n</code></pre>"},{"location":"v2/features/map/cuts/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/features/map/cuts/#spatial-query-optimization","title":"Spatial Query Optimization","text":"<p>Bounds Pre-Filter:</p> <p>Always pre-filter by bounding box before point-in-polygon:</p> <pre><code>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</code></pre> <p>Performance Impact:</p> <ul> <li>Without bounds pre-filter: 10,000 locations \u2192 10,000 point-in-polygon checks</li> <li>With bounds pre-filter: 10,000 locations \u2192 500 candidates \u2192 500 point-in-polygon checks (20x faster)</li> </ul>"},{"location":"v2/features/map/cuts/#polygon-simplification","title":"Polygon Simplification","text":"<p>Reduce Vertices for Large Cuts:</p> <p>Use Douglas-Peucker algorithm to simplify polygons while preserving shape:</p> <pre><code>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</code></pre> <p>Tolerance Guidelines:</p> <ul> <li>0.00001: High precision (\u00b11m), use for small neighborhoods</li> <li>0.0001: Medium precision (\u00b110m), use for wards</li> <li>0.001: Low precision (\u00b1100m), use for large districts</li> </ul>"},{"location":"v2/features/map/cuts/#caching-cut-queries","title":"Caching Cut Queries","text":"<p>Cache Frequently Used Cuts:</p> <pre><code>// 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</code></pre>"},{"location":"v2/features/map/cuts/#related-documentation","title":"Related Documentation","text":"<p>Backend Modules:</p> <ul> <li>Cuts Backend Module \u2014 API implementation</li> <li>Spatial Utils \u2014 Point-in-polygon algorithms</li> <li>Locations Service \u2014 Spatial filtering</li> </ul> <p>Frontend Pages:</p> <ul> <li>CutsPage \u2014 Admin CRUD interface</li> <li>CutDrawingMode \u2014 Polygon drawing</li> <li>CutOverlays \u2014 Map rendering</li> </ul> <p>Database:</p> <ul> <li>Cut Model \u2014 Cut schema</li> <li>Spatial Queries \u2014 Optimization tips</li> </ul> <p>Features:</p> <ul> <li>Locations \u2014 Location filtering by cut</li> <li>Shifts \u2014 Shift assignment to cuts</li> <li>Canvassing \u2014 Canvassing within cut boundaries</li> <li>Walk Sheets \u2014 Export locations by cut</li> </ul>"},{"location":"v2/features/map/data-quality/","title":"Data Quality Dashboard","text":""},{"location":"v2/features/map/data-quality/#overview","title":"Overview","text":"<p>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.</p> <p>Key Features:</p> <ul> <li>Real-time geocoding quality metrics</li> <li>Provider success rate tracking</li> <li>Low-confidence location detection</li> <li>Duplicate location identification</li> <li>Bulk re-geocoding operations</li> <li>Address validation reporting</li> <li>Interactive quality charts</li> <li>Export quality reports</li> </ul> <p>Use Cases:</p> <ul> <li>Monthly data quality audits</li> <li>NAR import validation</li> <li>Geocoding provider evaluation</li> <li>Pre-canvass data verification</li> <li>Address database cleanup</li> <li>Campaign planning accuracy checks</li> </ul> <p>Architecture Highlights:</p> <ul> <li>Aggregate statistics via database queries</li> <li>Confidence threshold filtering (0-100 scale)</li> <li>Provider performance comparison</li> <li>Duplicate detection via coordinate matching</li> <li>Manual review workflows</li> <li>Prometheus metrics integration</li> </ul>"},{"location":"v2/features/map/data-quality/#architecture","title":"Architecture","text":"<pre><code>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</code></pre> <p>Data Flow:</p> <ol> <li>Statistics Aggregation:</li> <li>Query all locations with geocoding metadata</li> <li>Calculate aggregate metrics (total, geocoded %, avg confidence)</li> <li>Group by provider for success rate comparison</li> <li>Identify low-confidence locations (< 50)</li> <li> <p>Detect duplicates via coordinate matching</p> </li> <li> <p>Quality Review:</p> </li> <li>Admin views dashboard statistics</li> <li>Filters low-confidence locations</li> <li>Reviews individual location details</li> <li> <p>Identifies patterns (provider failures, address format issues)</p> </li> <li> <p>Remediation:</p> </li> <li>Manual address correction</li> <li>Single location re-geocoding</li> <li>Bulk re-geocoding with different provider</li> <li> <p>Duplicate merging or marking</p> </li> <li> <p>Monitoring:</p> </li> <li>Prometheus metrics track quality trends</li> <li>Alert rules trigger for quality degradation</li> <li>Grafana dashboards visualize provider performance</li> </ol>"},{"location":"v2/features/map/data-quality/#database-models","title":"Database Models","text":""},{"location":"v2/features/map/data-quality/#location-model","title":"Location Model","text":"<pre><code>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</code></pre> <p>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)</p> <p>Geocode Provider Enum: <pre><code>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</code></pre></p>"},{"location":"v2/features/map/data-quality/#address-model","title":"Address Model","text":"<pre><code>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</code></pre>"},{"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":"<p>Fetch aggregate geocoding quality statistics.</p> <p>Authentication: Required (SUPER_ADMIN, MAP_ADMIN)</p> <p>Response: <pre><code>{\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</code></pre></p> <p>Implementation:</p> <pre><code>// 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</code></pre>"},{"location":"v2/features/map/data-quality/#get-apilocationsgeocodeconfidencelt50","title":"GET /api/locations?geocodeConfidence=lt:50","text":"<p>Fetch locations filtered by geocode confidence.</p> <p>Authentication: Required</p> <p>Query Parameters: - <code>geocodeConfidence</code> (filter): <code>lt:X</code>, <code>gt:X</code>, <code>eq:X</code>, <code>null</code> - <code>geocodeProvider</code> (filter): Provider name (GOOGLE, MAPBOX, etc.) - <code>page</code> (optional): Page number (default: 1) - <code>limit</code> (optional): Results per page (default: 50) - <code>sortBy</code> (optional): Field to sort by (default: \"geocodeConfidence\") - <code>order</code> (optional): \"asc\" or \"desc\" (default: \"asc\")</p> <p>Examples:</p> <pre><code>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</code></pre> <p>Response: <pre><code>{\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</code></pre></p>"},{"location":"v2/features/map/data-quality/#get-apilocationsduplicates","title":"GET /api/locations/duplicates","text":"<p>Identify locations with identical coordinates.</p> <p>Authentication: Required (SUPER_ADMIN, MAP_ADMIN)</p> <p>Query Parameters: - <code>threshold</code> (optional): Distance threshold in meters (default: 1, matches exact duplicates)</p> <p>Response: <pre><code>{\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</code></pre></p> <p>Implementation:</p> <pre><code>// 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</code></pre>"},{"location":"v2/features/map/data-quality/#post-apilocationsidregeocode","title":"POST /api/locations/:id/regeocode","text":"<p>Re-geocode a single location with specified provider.</p> <p>Authentication: Required (SUPER_ADMIN, MAP_ADMIN)</p> <p>Request Body: <pre><code>{\n \"provider\": \"GOOGLE\",\n \"address\": \"123 Main St, Toronto ON M5H 2N2\"\n}\n</code></pre></p> <p>Parameters: - <code>provider</code> (optional): Specific provider to use (default: fallback chain) - <code>address</code> (optional): Override address string (default: use existing)</p> <p>Response: <pre><code>{\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</code></pre></p>"},{"location":"v2/features/map/data-quality/#post-apilocationsbulk-geocode","title":"POST /api/locations/bulk-geocode","text":"<p>Bulk re-geocode multiple locations.</p> <p>Authentication: Required (SUPER_ADMIN, MAP_ADMIN)</p> <p>Request Body: <pre><code>{\n \"locationIds\": [1001, 1002, 1003],\n \"provider\": \"GOOGLE\",\n \"confidenceThreshold\": 50\n}\n</code></pre></p> <p>Parameters: - <code>locationIds</code> (optional): Specific location IDs (default: all with confidence < threshold) - <code>provider</code> (optional): Specific provider to use (default: fallback chain) - <code>confidenceThreshold</code> (optional): Only re-geocode locations below this confidence (default: 50)</p> <p>Response: <pre><code>{\n \"jobId\": \"bulk-geocode-20250213-103000\",\n \"status\": \"queued\",\n \"total\": 150,\n \"message\": \"Bulk geocoding job started\"\n}\n</code></pre></p> <p>Job Progress Endpoint: <pre><code>GET /api/locations/bulk-geocode/:jobId\n</code></pre></p> <p>Job Status Response: <pre><code>{\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</code></pre></p>"},{"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":"<p>Custom Metrics:</p> <pre><code>// 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</code></pre> <p>Alert Rules:</p> <pre><code># 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</code></pre>"},{"location":"v2/features/map/data-quality/#quality-metrics","title":"Quality Metrics","text":""},{"location":"v2/features/map/data-quality/#geocoding-confidence","title":"Geocoding Confidence","text":"<p>Calculation:</p> <p>Geocoding confidence is calculated based on multiple factors:</p> <pre><code>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</code></pre> <p>Confidence Levels:</p> <ul> <li>81-100 (Excellent): Exact match with full address components</li> <li>61-80 (Good): Interpolated match with most components</li> <li>41-60 (Medium): Approximate match, missing some components</li> <li>21-40 (Low): Fallback geocoding, significant uncertainty</li> <li>0-20 (Very Low): Minimal match, likely incorrect</li> </ul>"},{"location":"v2/features/map/data-quality/#provider-success-rates","title":"Provider Success Rates","text":"<p>Metrics Tracked:</p> <pre><code>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</code></pre> <p>Success Rate Calculation:</p> <pre><code>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</code></pre>"},{"location":"v2/features/map/data-quality/#duplicate-detection","title":"Duplicate Detection","text":"<p>Detection Methods:</p> <ol> <li> <p>Exact Coordinate Match: <pre><code>// 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</code></pre></p> </li> <li> <p>Proximity Threshold: <pre><code>// 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</code></pre></p> </li> <li> <p>Address Similarity: <pre><code>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</code></pre></p> </li> </ol>"},{"location":"v2/features/map/data-quality/#address-validation","title":"Address Validation","text":"<p>Validation Checks:</p> <pre><code>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</code></pre>"},{"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":"<p>Step 1: Access Dashboard</p> <ol> <li>Log in as SUPER_ADMIN or MAP_ADMIN</li> <li>Click Map in sidebar</li> <li>Click Data Quality submenu</li> <li>Dashboard loads with statistics</li> </ol> <p>Step 2: Review Overall Statistics</p> <p>Dashboard displays 4 main statistic cards:</p> <pre><code>\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</code></pre> <p>Step 3: Analyze Provider Performance</p> <p>Provider breakdown table shows:</p> Provider Count Success Rate Avg Confidence GOOGLE 800 99.2% 85.3 MAPBOX 350 97.1% 82.1 NOMINATIM 200 94.5% 75.8 PHOTON 100 91.0% 68.2 UNKNOWN 50 N/A 0 <p>Step 4: Review Confidence Distribution</p> <p>Bar chart displays confidence distribution:</p> <pre><code>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</code></pre>"},{"location":"v2/features/map/data-quality/#identify-and-review-low-confidence-locations","title":"Identify and Review Low-Confidence Locations","text":"<p>Step 1: Filter Low-Confidence Locations</p> <ol> <li>Click Low Confidence tab on dashboard</li> <li>Table loads with locations where confidence < 50</li> <li>Sort by confidence (ascending) to prioritize worst</li> </ol> <p>Step 2: Review Location Details</p> <p>Click row to open detail drawer:</p> <pre><code>\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</code></pre> <p>Step 3: Take Action</p> <p>Options for remediation:</p> <ol> <li>Re-geocode with different provider:</li> <li>Click Re-geocode button</li> <li>Select provider (GOOGLE recommended for low confidence)</li> <li>Click Geocode Now</li> <li> <p>New confidence displayed</p> </li> <li> <p>Edit address:</p> </li> <li>Click Edit Address</li> <li>Correct typos or formatting issues</li> <li>Save changes</li> <li> <p>Auto-triggers re-geocoding</p> </li> <li> <p>View on map:</p> </li> <li>Click View Map</li> <li>Verify location accuracy visually</li> <li>Drag marker to correct position if needed</li> </ol>"},{"location":"v2/features/map/data-quality/#bulk-re-geocoding","title":"Bulk Re-geocoding","text":"<p>Step 1: Select Locations</p> <ol> <li>In Low Confidence tab, use table checkboxes to select locations</li> <li>Or click Select All to select all visible</li> <li>Selected count displays: \"50 selected\"</li> </ol> <p>Step 2: Choose Provider</p> <ol> <li>Click Bulk Re-geocode button</li> <li>Modal opens with provider selection: <pre><code>\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n\u2502 Bulk Re-geocode \u2502\n\u251c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524\n\u2502 Re-geocode 50 locations \u2502\n\u2502 \u2502\n\u2502 Provider: [GOOGLE \u25bc] \u2502\n\u2502 \u2502\n\u2502 Options: \u2502\n\u2502 \u2611 Only if confidence < 50 \u2502\n\u2502 \u2611 Cache results \u2502\n\u2502 \u2610 Overwrite existing coordinates \u2502\n\u2502 \u2502\n\u2502 Estimated time: ~2 minutes \u2502\n\u2502 \u2502\n\u2502 [Cancel] [Start Re-geocoding] \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n</code></pre></li> </ol> <p>Step 3: Monitor Progress</p> <ol> <li> <p>Job starts, progress bar appears: <pre><code>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</code></pre></p> </li> <li> <p>Real-time updates:</p> </li> <li>Total processed</li> <li>Successful geocodes</li> <li>Failed geocodes</li> <li>Average new confidence</li> </ol> <p>Step 4: Review Results</p> <p>Job completion summary:</p> <pre><code>\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</code></pre>"},{"location":"v2/features/map/data-quality/#handle-duplicates","title":"Handle Duplicates","text":"<p>Step 1: View Duplicates Tab</p> <ol> <li>Click Duplicates tab on dashboard</li> <li>Table groups locations by coordinates</li> </ol> <p>Step 2: Review Duplicate Groups</p> <p>Table displays:</p> 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] <p>Step 3: Resolve Duplicates</p> <p>Click Review to open resolution modal:</p> <pre><code>\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</code></pre> <p>Resolution Options:</p> <ol> <li>Merge: Combine into single Location with multiple Address records</li> <li>Multi-unit: Mark as legitimate multi-unit building</li> <li>Re-geocode: Attempt to get unique coordinates for each</li> </ol>"},{"location":"v2/features/map/data-quality/#quality-improvement-strategies","title":"Quality Improvement Strategies","text":""},{"location":"v2/features/map/data-quality/#multi-provider-geocoding","title":"Multi-Provider Geocoding","text":"<p>Fallback Chain:</p> <pre><code>// 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</code></pre> <p>Benefits: - Increases success rate (90% \u2192 96%+) - Reduces dependency on single provider - Cost optimization (use free providers as fallback) - Provider outage resilience</p>"},{"location":"v2/features/map/data-quality/#address-normalization","title":"Address Normalization","text":"<p>Pre-Geocoding Normalization:</p> <pre><code>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</code></pre> <p>Improvements: - Reduces geocoding errors by 10-15% - Increases confidence scores - Better cache hit rate</p>"},{"location":"v2/features/map/data-quality/#geocoding-cache","title":"Geocoding Cache","text":"<p>Redis Cache Implementation:</p> <pre><code>// 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</code></pre> <p>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</p>"},{"location":"v2/features/map/data-quality/#manual-verification","title":"Manual Verification","text":"<p>Critical Location Verification:</p> <p>Manually verify high-priority locations:</p> <ol> <li>Campaign offices: Ensure exact coordinates</li> <li>Shift start points: Verify accessibility</li> <li>Event venues: Confirm entrance location</li> <li>Polling stations: Critical for voter info</li> </ol> <p>Verification Process:</p> <pre><code>// 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</code></pre>"},{"location":"v2/features/map/data-quality/#regular-audits","title":"Regular Audits","text":"<p>Monthly Quality Audit Checklist:</p> <ol> <li> <p>Run quality report: <pre><code>curl http://localhost:4000/api/locations/geocode-stats\n</code></pre></p> </li> <li> <p>Check metrics against thresholds:</p> </li> <li>Geocoded % > 95%</li> <li>Avg confidence > 70</li> <li>Low confidence count < 50</li> <li> <p>Duplicates < 20</p> </li> <li> <p>Review low-confidence locations:</p> </li> <li>Filter locations with confidence < 50</li> <li>Review top 20 by address</li> <li> <p>Identify patterns (specific streets, providers)</p> </li> <li> <p>Bulk re-geocode low confidence:</p> </li> <li>Use GOOGLE provider for accuracy</li> <li> <p>Monitor improvement in avg confidence</p> </li> <li> <p>Resolve duplicates:</p> </li> <li>Review all duplicate groups</li> <li>Merge or mark as multi-unit</li> <li> <p>Update addresses as needed</p> </li> <li> <p>Export quality report: <pre><code>const report = await generateQualityReport();\nfs.writeFileSync(`quality-report-${date}.json`, JSON.stringify(report, null, 2));\n</code></pre></p> </li> </ol>"},{"location":"v2/features/map/data-quality/#code-examples","title":"Code Examples","text":""},{"location":"v2/features/map/data-quality/#dataqualitydashboardpagetsx","title":"DataQualityDashboardPage.tsx","text":"<pre><code>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</code></pre>"},{"location":"v2/features/map/data-quality/#geocode-statistics-service","title":"Geocode Statistics Service","text":"<pre><code>// 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</code></pre>"},{"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":"<p>Symptoms: - > 100 locations with confidence < 50 - Avg confidence < 60 - Prometheus alert firing</p> <p>Solutions:</p> <ol> <li> <p>Check provider API keys: <pre><code># 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</code></pre></p> </li> <li> <p>Try different primary provider: <pre><code># In .env, change primary provider\nGEOCODE_PRIMARY_PROVIDER=GOOGLE # Most accurate\n# Or try:\nGEOCODE_PRIMARY_PROVIDER=MAPBOX # Good alternative\n</code></pre></p> </li> <li> <p>Verify address format: <pre><code>// Bad: Missing city/postal\n\"123 Main St\"\n\n// Good: Full address\n\"123 Main St, Toronto ON M5H 2N2\"\n</code></pre></p> </li> <li> <p>Use postal code for better accuracy: <pre><code>// Append postal code if available\nconst fullAddress = location.postalCode\n ? `${location.address}, ${location.postalCode}`\n : location.address;\n</code></pre></p> </li> <li> <p>Bulk re-geocode with Google: <pre><code># Via API\ncurl -X POST http://localhost:4000/api/locations/bulk-geocode \\\n -H \"Authorization: Bearer $TOKEN\" \\\n -d '{\"provider\":\"GOOGLE\",\"confidenceThreshold\":50}'\n</code></pre></p> </li> </ol>"},{"location":"v2/features/map/data-quality/#problem-duplicate-locations-detected","title":"Problem: Duplicate locations detected","text":"<p>Symptoms: - Multiple locations at same coordinates - Duplicates tab shows many groups - Inflated location counts in cuts</p> <p>Solutions:</p> <ol> <li> <p>Check if legitimately multi-unit: <pre><code>-- 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</code></pre></p> </li> <li> <p>Verify geocoding precision: <pre><code>// 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</code></pre></p> </li> <li> <p>Review NAR import process: <pre><code>// 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</code></pre></p> </li> <li> <p>Merge duplicates: <pre><code>// 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</code></pre></p> </li> </ol>"},{"location":"v2/features/map/data-quality/#problem-geocoding-stats-slow-to-load","title":"Problem: Geocoding stats slow to load","text":"<p>Symptoms: - GET /api/locations/geocode-stats takes > 5 seconds - Dashboard timeout errors - High database CPU</p> <p>Solutions:</p> <ol> <li> <p>Add database indexes: <pre><code>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</code></pre></p> </li> <li> <p>Cache stats in Redis: <pre><code>// 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</code></pre></p> </li> <li> <p>Use aggregation pipeline: <pre><code>// 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</code></pre></p> </li> <li> <p>Materialize stats view: <pre><code>-- 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</code></pre></p> </li> </ol>"},{"location":"v2/features/map/data-quality/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/features/map/data-quality/#database-query-optimization","title":"Database Query Optimization","text":"<p>Indexes: - <code>geocodeConfidence</code> (filtering) - <code>geocodeProvider</code> (grouping) - <code>(latitude, longitude)</code> composite (duplicate detection) - Partial index on non-null coordinates</p> <p>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)</p>"},{"location":"v2/features/map/data-quality/#api-rate-limits","title":"API Rate Limits","text":"<p>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</p> <p>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</p>"},{"location":"v2/features/map/data-quality/#caching-strategy","title":"Caching Strategy","text":"<p>Cache Layers:</p> <ol> <li> <p>Application Cache (Redis): <pre><code>// 30-day TTL for geocode results\nconst cacheKey = `geocode:${normalizeAddress(address)}`;\nawait redis.setex(cacheKey, 2592000, JSON.stringify(result));\n</code></pre></p> </li> <li> <p>Statistics Cache: <pre><code>// 5-minute TTL for stats\nawait redis.setex('geocode:stats', 300, JSON.stringify(stats));\n</code></pre></p> </li> <li> <p>Provider Response Cache: <pre><code>// Cache raw provider responses separately\nawait redis.setex(`provider:${provider}:${address}`, 604800, JSON.stringify(rawResponse));\n</code></pre></p> </li> </ol> <p>Cache Hit Rates: - Geocoding: 90%+ (repeated addresses) - Statistics: 95%+ (frequent dashboard views) - Provider responses: 85%+ (re-geocoding attempts)</p>"},{"location":"v2/features/map/data-quality/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/features/map/data-quality/#backend-documentation","title":"Backend Documentation","text":"<ul> <li>Locations Service: <code>api/src/modules/map/locations/locations.service.ts</code></li> <li>Geocode stats aggregation</li> <li>Duplicate detection</li> <li> <p>Re-geocoding operations</p> </li> <li> <p>Geocoding Service: <code>api/src/modules/map/geocoding/geocoding.service.ts</code></p> </li> <li>Multi-provider fallback</li> <li>Confidence calculation</li> <li> <p>Cache integration</p> </li> <li> <p>Bulk Geocoding: <code>api/src/modules/map/locations/bulk-geocode.routes.ts</code></p> </li> <li>Job queue integration</li> <li>Progress tracking</li> <li>Error handling</li> </ul>"},{"location":"v2/features/map/data-quality/#frontend-documentation","title":"Frontend Documentation","text":"<ul> <li>Data Quality Dashboard: <code>admin/src/pages/DataQualityDashboardPage.tsx</code></li> <li>Statistics display</li> <li>Charts and tables</li> <li> <p>Bulk actions</p> </li> <li> <p>Locations Page: <code>admin/src/pages/LocationsPage.tsx</code></p> </li> <li>CSV import/export</li> <li>Inline geocoding</li> <li>Address editing</li> </ul>"},{"location":"v2/features/map/data-quality/#database-documentation","title":"Database Documentation","text":"<ul> <li>Location Model: <code>api/prisma/schema.prisma</code></li> <li>Geocoding metadata fields</li> <li>Indexes for performance</li> <li>Relations to Address</li> </ul>"},{"location":"v2/features/map/data-quality/#monitoring-documentation","title":"Monitoring Documentation","text":"<ul> <li>Prometheus Metrics: <code>api/src/utils/metrics.ts</code></li> <li>Custom geocoding metrics</li> <li>Quality gauges</li> <li> <p>Alert integration</p> </li> <li> <p>Grafana Dashboard: <code>configs/grafana/dashboards/data-quality.json</code></p> </li> <li>Quality trend charts</li> <li>Provider comparison</li> <li>Alert visualization</li> </ul>"},{"location":"v2/features/map/data-quality/#external-resources","title":"External Resources","text":"<ul> <li>Google Geocoding API: https://developers.google.com/maps/documentation/geocoding</li> <li>Mapbox Geocoding API: https://docs.mapbox.com/api/search/geocoding</li> <li>Nominatim API: https://nominatim.org/release-docs/latest/api/Search</li> <li>Photon API: https://photon.komoot.io</li> </ul>"},{"location":"v2/features/map/geocoding/","title":"Multi-Provider Geocoding Service","text":""},{"location":"v2/features/map/geocoding/#overview","title":"Overview","text":"<p>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.</p> <p>Key Capabilities:</p> <ul> <li>6 Geocoding Providers: Google, Mapbox, Nominatim, Photon, LocationIQ, ArcGIS</li> <li>Provider Fallback Chain: Try providers in order until success</li> <li>Confidence Scoring: 0-100 score based on match quality</li> <li>Redis Caching: 7-day TTL to avoid redundant API calls</li> <li>Bulk Queue Processing: BullMQ integration for large geocoding jobs</li> <li>Address Normalization: Expand abbreviations, normalize postal codes</li> <li>Reverse Geocoding: Convert coordinates to human-readable address</li> <li>Provider Health Tracking: Prometheus metrics for success rates</li> </ul> <p>Use Cases:</p> <ul> <li>Bulk geocoding of voter files</li> <li>Real-time address validation during data entry</li> <li>Map marker placement for locations</li> <li>Address autocomplete (future)</li> <li>Spatial filtering by coordinates</li> <li>Walk sheet generation with accurate maps</li> </ul>"},{"location":"v2/features/map/geocoding/#architecture","title":"Architecture","text":"<pre><code>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</code></pre> <p>Flow Description:</p> <ol> <li>Location service requests geocode \u2192 Geocoding service checks Redis cache</li> <li>Cache miss \u2192 Try providers in configured order (Google \u2192 Mapbox \u2192 Nominatim \u2192 Photon \u2192 LocationIQ \u2192 ArcGIS)</li> <li>Provider success \u2192 Calculate confidence score (0-100) based on match type</li> <li>Cache result \u2192 Store in Redis with 7-day TTL</li> <li>Bulk geocoding \u2192 BullMQ worker processes batches with rate limiting</li> <li>Metrics tracking \u2192 Prometheus gauges for provider health and cache hit rate</li> </ol>"},{"location":"v2/features/map/geocoding/#database-models","title":"Database Models","text":""},{"location":"v2/features/map/geocoding/#geocodeprovider-enum","title":"GeocodeProvider Enum","text":"<p>See Location Model Documentation for full schema.</p> <p>Provider Enum Values:</p> <pre><code>enum GeocodeProvider {\n GOOGLE\n MAPBOX\n NOMINATIM\n PHOTON\n LOCATIONIQ\n ARCGIS\n UNKNOWN\n}\n</code></pre> <p>Location Model Geocoding Fields:</p> <ul> <li><code>latitude</code> / <code>longitude</code>: Decimal coordinates from geocoding</li> <li><code>geocodeConfidence</code>: Integer 0-100 (>90=high, 70-90=medium, <70=low)</li> <li><code>geocodeProvider</code>: Which provider successfully geocoded</li> <li><code>geocodeAttempts</code>: Number of failed attempts (for retry logic)</li> <li><code>lastGeocodeAttempt</code>: Timestamp of last geocoding attempt</li> </ul> <p>Related Models:</p> <ul> <li>Location \u2014 Stores geocoded coordinates</li> <li>LocationHistory \u2014 Audit trail for geocoding changes</li> </ul>"},{"location":"v2/features/map/geocoding/#api-endpoints","title":"API Endpoints","text":"<p>See Geocoding Backend Module Documentation for full API reference.</p> <p>Geocoding Endpoints:</p> Method Endpoint Auth Description POST <code>/api/map/locations/geocode</code> MAP_ADMIN Geocode single address POST <code>/api/map/locations/reverse-geocode</code> MAP_ADMIN Reverse geocode lat/lng to address POST <code>/api/map/locations/bulk-geocode/start</code> MAP_ADMIN Start bulk geocoding job (BullMQ) GET <code>/api/map/locations/bulk-geocode/status</code> MAP_ADMIN Check bulk geocoding job status POST <code>/api/map/locations/bulk-geocode/cancel</code> MAP_ADMIN Cancel running bulk geocoding job <p>Request/Response Examples:</p> <p>Single Geocode Request:</p> <pre><code>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</code></pre> <p>Bulk Geocode Job:</p> <pre><code>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</code></pre>"},{"location":"v2/features/map/geocoding/#configuration","title":"Configuration","text":""},{"location":"v2/features/map/geocoding/#environment-variables","title":"Environment Variables","text":"Variable Type Default Description <code>GEOCODING_ENABLED</code> boolean <code>true</code> Enable geocoding services <code>GEOCODING_CACHE_ENABLED</code> boolean <code>true</code> Cache results in Redis <code>GEOCODING_CACHE_TTL_HOURS</code> number <code>168</code> Cache TTL (7 days) <code>GEOCODING_PROVIDERS</code> string <code>GOOGLE,MAPBOX,NOMINATIM,PHOTON,LOCATIONIQ,ARCGIS</code> Provider order (comma-separated) <code>GOOGLE_MAPS_API_KEY</code> string - Google Geocoding API key (required if Google enabled) <code>MAPBOX_ACCESS_TOKEN</code> string - Mapbox API token (required if Mapbox enabled) <code>LOCATIONIQ_API_KEY</code> string - LocationIQ API key (required if LocationIQ enabled) <code>NOMINATIM_BASE_URL</code> string <code>https://nominatim.openstreetmap.org</code> Nominatim API URL <code>PHOTON_BASE_URL</code> string <code>https://photon.komoot.io</code> Photon API URL <code>ARCGIS_BASE_URL</code> string <code>https://geocode.arcgis.com/arcgis/rest/services/World/GeocodeServer</code> ArcGIS API URL"},{"location":"v2/features/map/geocoding/#provider-configuration","title":"Provider Configuration","text":"<p>Provider Selection Strategy:</p> <ol> <li>Free tier exhausted? Remove provider from chain</li> <li>Rate limit hit? Skip provider temporarily (5min cooldown)</li> <li>Service down? Skip provider (exponential backoff)</li> <li>Low confidence? Try next provider</li> </ol> <p>Provider Priority (Default):</p> <ol> <li>Google \u2014 Best accuracy, paid API (free $200/month credit)</li> <li>Mapbox \u2014 Good accuracy, generous free tier (100k/month)</li> <li>Nominatim \u2014 Free, moderate accuracy, 1 req/sec limit</li> <li>Photon \u2014 Free, fast, good for European addresses</li> <li>LocationIQ \u2014 Free tier (5k/day), good international coverage</li> <li>ArcGIS \u2014 Free tier (20k/month), good US coverage</li> </ol>"},{"location":"v2/features/map/geocoding/#confidence-scoring-rules","title":"Confidence Scoring Rules","text":"<p>Confidence Score Calculation:</p> Match Type Google Mapbox Nominatim Photon LocationIQ ArcGIS Rooftop (exact address) 95-100 95-100 90-95 90-95 90-95 95-100 Interpolated 85-94 85-94 80-89 80-89 80-89 85-94 Street-level 70-84 70-84 65-79 65-79 65-79 70-84 Postal code 50-69 50-69 45-64 45-64 45-64 50-69 City 30-49 30-49 25-44 25-44 25-44 30-49 Province/State 10-29 10-29 5-24 5-24 5-24 10-29 Country 0-9 0-9 0-4 0-4 0-4 0-9 <p>Confidence Thresholds:</p> <ul> <li>High (90-100): Exact address match, suitable for door-knocking</li> <li>Medium (70-89): Street-level or interpolated, suitable for mapping</li> <li>Low (50-69): Postal code or city-level, needs manual verification</li> <li>None (<50): Unreliable, should re-geocode or manually enter coordinates</li> </ul>"},{"location":"v2/features/map/geocoding/#admin-workflow","title":"Admin Workflow","text":""},{"location":"v2/features/map/geocoding/#single-address-geocoding","title":"Single Address Geocoding","text":"<p>Step 1: Enter Address</p> <p>On LocationsPage create/edit form, enter address:</p> <pre><code>Address: 123 Main Street\nPostal Code: K1A 0B1\n</code></pre> <p>Step 2: Click Geocode Button</p> <p>Click Geocode button below address field.</p> <p>Step 3: View Results</p> <p>System displays:</p> <ul> <li>Latitude/Longitude: Auto-populated</li> <li>Confidence Score: 95% (High)</li> <li>Provider: Google</li> <li>Formatted Address: 123 Main St, Ottawa, ON K1A 0B1, Canada</li> </ul> <p>Step 4: Save Location</p> <p>Click Save to create/update location with geocoded coordinates.</p>"},{"location":"v2/features/map/geocoding/#bulk-re-geocoding","title":"Bulk Re-Geocoding","text":"<p>Use Case: Re-geocode locations with missing or low-confidence coordinates.</p> <p>Step 1: Open Bulk Geocode Modal</p> <p>On LocationsPage, click Bulk Re-Geocode button.</p> <p>Step 2: Configure Job</p> <p>Set parameters:</p> <ul> <li>Confidence Threshold: Only geocode locations below this score (e.g., 70)</li> <li>Missing Only: Only geocode locations without coordinates</li> <li>Provider: Choose preferred provider (or use default chain)</li> <li>Batch Size: Locations per batch (default: 50)</li> </ul> <p>Step 3: Start Job</p> <p>Click Start Job to queue job in BullMQ.</p> <p>Step 4: Monitor Progress</p> <p>View real-time progress:</p> <ul> <li>Completed: 234 / 1000 locations</li> <li>Failed: 12 locations</li> <li>Progress: 23.4%</li> <li>ETA: 8 minutes</li> </ul> <p>Step 5: Review Results</p> <p>After job completes:</p> <ul> <li>Success Rate: 98.8%</li> <li>Average Confidence: 87.3</li> <li>Failed Addresses: Download CSV of failures</li> </ul> <p>Step 6: Retry Failures (Optional)</p> <p>For failed addresses:</p> <ol> <li>Download failure CSV</li> <li>Manually verify addresses</li> <li>Fix typos/formatting issues</li> <li>Re-import CSV</li> <li>Run bulk geocode again</li> </ol>"},{"location":"v2/features/map/geocoding/#reverse-geocoding","title":"Reverse Geocoding","text":"<p>Use Case: Convert map click coordinates to address.</p> <p>Step 1: Click Map</p> <p>On AdminMapView, click location to get lat/lng.</p> <p>Step 2: Reverse Geocode</p> <p>Click Reverse Geocode button in popup.</p> <p>Step 3: View Address</p> <p>System displays:</p> <pre><code>Address: 123 Main St\nCity: Ottawa\nProvince: ON\nCountry: Canada\n</code></pre> <p>Step 4: Create Location</p> <p>Click Create Location to auto-fill address form.</p>"},{"location":"v2/features/map/geocoding/#code-examples","title":"Code Examples","text":""},{"location":"v2/features/map/geocoding/#geocoding-service-backend","title":"Geocoding Service (Backend)","text":"<pre><code>// 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</code></pre>"},{"location":"v2/features/map/geocoding/#provider-chain-implementation","title":"Provider Chain Implementation","text":"<pre><code>// 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</code></pre>"},{"location":"v2/features/map/geocoding/#google-geocoding-provider","title":"Google Geocoding Provider","text":"<pre><code>// 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</code></pre>"},{"location":"v2/features/map/geocoding/#mapbox-geocoding-provider","title":"Mapbox Geocoding Provider","text":"<pre><code>// 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</code></pre>"},{"location":"v2/features/map/geocoding/#nominatim-geocoding-provider","title":"Nominatim Geocoding Provider","text":"<pre><code>// 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</code></pre>"},{"location":"v2/features/map/geocoding/#address-normalization","title":"Address Normalization","text":"<pre><code>// 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</code></pre>"},{"location":"v2/features/map/geocoding/#redis-caching","title":"Redis Caching","text":"<pre><code>// 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</code></pre>"},{"location":"v2/features/map/geocoding/#bulk-geocoding-job-bullmq","title":"Bulk Geocoding Job (BullMQ)","text":"<pre><code>// 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</code></pre>"},{"location":"v2/features/map/geocoding/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/features/map/geocoding/#issue-all-providers-failing","title":"Issue: All Providers Failing","text":"<p>Symptoms:</p> <ul> <li>\"All geocoding providers failed\" error</li> <li>Geocode confidence always 0</li> <li>No results from any provider</li> </ul> <p>Causes:</p> <ul> <li>All API keys invalid or missing</li> <li>Network connectivity issues</li> <li>Rate limits exceeded on all providers</li> <li>Address format not recognized</li> </ul> <p>Solutions:</p> <ol> <li>Verify API keys:</li> </ol> <pre><code># 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</code></pre> <ol> <li>Check provider health:</li> </ol> <pre><code># View Prometheus metrics\ncurl http://localhost:4000/metrics | grep cm_geocode\n\n# View API logs\ndocker compose logs -f api | grep geocode\n</code></pre> <ol> <li>Test with free provider (Nominatim):</li> </ol> <pre><code># 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</code></pre>"},{"location":"v2/features/map/geocoding/#issue-low-confidence-scores","title":"Issue: Low Confidence Scores","text":"<p>Symptoms:</p> <ul> <li>Geocode confidence consistently <70</li> <li>Coordinates appear incorrect on map</li> <li>Addresses geocoded to city-level instead of street-level</li> </ul> <p>Causes:</p> <ul> <li>Address format ambiguous (missing street type, postal code)</li> <li>Provider using city centroid instead of exact address</li> <li>International address format not recognized</li> <li>Address doesn't exist in provider database</li> </ul> <p>Solutions:</p> <ol> <li>Improve address format:</li> </ol> <pre><code>// 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</code></pre> <ol> <li>Try different providers:</li> </ol> <pre><code># 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</code></pre> <ol> <li>Manual verification:</li> </ol> <p>For critical addresses, manually verify coordinates:</p> <pre><code># 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</code></pre>"},{"location":"v2/features/map/geocoding/#issue-bulk-geocoding-job-stuck","title":"Issue: Bulk Geocoding Job Stuck","text":"<p>Symptoms:</p> <ul> <li>Bulk geocode progress stuck at X%</li> <li>Job running for hours without completing</li> <li>BullMQ job marked as \"active\" but not processing</li> </ul> <p>Causes:</p> <ul> <li>Worker crashed mid-job</li> <li>Rate limit hit (paused for cooldown)</li> <li>Redis connection lost</li> <li>Job timeout (default: 30min)</li> </ul> <p>Solutions:</p> <ol> <li>Check job status:</li> </ol> <pre><code># 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</code></pre> <ol> <li>Restart worker:</li> </ol> <pre><code># Restart API service (worker runs in API container)\ndocker compose restart api\n</code></pre> <ol> <li>Cancel stuck job:</li> </ol> <pre><code># 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</code></pre> <ol> <li>Increase timeout:</li> </ol> <pre><code>// api/src/services/geocode-queue.service.ts\ndefaultJobOptions: {\n timeout: 3600000, // 1 hour (was 30min)\n}\n</code></pre>"},{"location":"v2/features/map/geocoding/#issue-cache-not-working","title":"Issue: Cache Not Working","text":"<p>Symptoms:</p> <ul> <li><code>cm_geocode_cache_hits</code> metric always 0</li> <li>Same address geocoded multiple times</li> <li>High API usage for repeated addresses</li> </ul> <p>Causes:</p> <ul> <li>Redis not running</li> <li><code>GEOCODING_CACHE_ENABLED=false</code></li> <li>Cache keys expiring too quickly</li> <li>Address normalization inconsistent (cache miss due to formatting)</li> </ul> <p>Solutions:</p> <ol> <li>Verify Redis connection:</li> </ol> <pre><code># 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</code></pre> <ol> <li>Check cache keys:</li> </ol> <pre><code># 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</code></pre> <ol> <li>Enable caching:</li> </ol> <pre><code># Verify in .env\nGEOCODING_CACHE_ENABLED=true\nGEOCODING_CACHE_TTL_HOURS=168 # 7 days\n</code></pre> <ol> <li>Clear cache to test:</li> </ol> <pre><code># Delete all geocode cache keys\ndocker compose exec redis redis-cli --scan --pattern \"GEOCODE_CACHE:*\" | xargs docker compose exec redis redis-cli DEL\n</code></pre>"},{"location":"v2/features/map/geocoding/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/features/map/geocoding/#provider-rate-limits","title":"Provider Rate Limits","text":"<p>Free Tier Limits:</p> 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 <p>*Self-hosted Photon recommended for production high-volume use.</p> <p>Best Practices:</p> <ol> <li>Enable Redis caching (7-day TTL reduces API calls by ~80%)</li> <li>Use bulk geocoding jobs (BullMQ queue with 1s delay between batches)</li> <li>Prefer NAR imports (coordinates included, no geocoding needed)</li> <li>Set up Photon self-hosted (for high-volume European campaigns)</li> </ol>"},{"location":"v2/features/map/geocoding/#caching-strategy","title":"Caching Strategy","text":"<p>Cache Hit Rate Optimization:</p> <pre><code>// 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</code></pre> <p>TTL Configuration:</p> <ul> <li>Development: 24 hours (test address changes)</li> <li>Production: 7 days (balance freshness vs API quota)</li> <li>NAR imports: 30 days (addresses rarely change)</li> </ul>"},{"location":"v2/features/map/geocoding/#bulk-geocoding-performance","title":"Bulk Geocoding Performance","text":"<p>Batch Size Tuning:</p> <pre><code>// 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</code></pre> <p>Optimal Settings:</p> Provider Batch Size Delay Between Batches Google 50 1s Mapbox 100 10s Nominatim 1 1s (strict rate limit) Photon 50 0s (self-hosted) <p>Prometheus Metrics:</p> <pre><code># 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</code></pre>"},{"location":"v2/features/map/geocoding/#related-documentation","title":"Related Documentation","text":"<p>Backend Modules:</p> <ul> <li>Geocoding Backend Module \u2014 Full service implementation</li> <li>Locations Service \u2014 Geocoding integration</li> <li>Geocode Queue Service \u2014 BullMQ worker</li> </ul> <p>Frontend Pages:</p> <ul> <li>LocationsPage \u2014 Geocoding UI</li> <li>Data Quality Dashboard \u2014 Confidence metrics</li> </ul> <p>Database:</p> <ul> <li>Location Model \u2014 Geocoding fields</li> <li>GeocodeProvider Enum \u2014 Provider types</li> </ul> <p>Features:</p> <ul> <li>Locations \u2014 Location management system</li> <li>Data Quality Dashboard \u2014 Geocoding quality metrics</li> <li>NAR Import \u2014 Canadian electoral data (pre-geocoded)</li> </ul> <p>Configuration:</p> <ul> <li>Environment Variables \u2014 Provider setup</li> <li>Redis Configuration \u2014 Cache setup</li> </ul>"},{"location":"v2/features/map/locations/","title":"Location Management System","text":""},{"location":"v2/features/map/locations/#overview","title":"Overview","text":"<p>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.</p> <p>Key Capabilities:</p> <ul> <li>Building + Unit Architecture: Location (building) has 1:N Address (units) for multi-unit buildings</li> <li>NAR Integration: Import Canadian electoral data (LOC_GUID, ADDR_GUID from Elections Canada)</li> <li>Multi-Provider Geocoding: Automatically geocode addresses with confidence scoring</li> <li>CSV Import/Export: Bulk operations for campaign data management</li> <li>Support Level Tracking: LEVEL_1 (Strong) \u2192 LEVEL_4 (Opposed) classification</li> <li>Spatial Filtering: Filter locations by polygon cuts or bounding box</li> <li>History Tracking: Complete audit trail of location changes</li> <li>Field Data: Sign tracking, building notes, federal district assignment</li> </ul> <p>Use Cases:</p> <ul> <li>Voter file management for electoral campaigns</li> <li>Door-to-door canvassing organization</li> <li>Sign placement tracking (lawn signs, window signs)</li> <li>Multi-unit building canvassing (apartments, condos)</li> <li>Federal electoral district mapping</li> <li>NAR 2025 import for Canadian campaigns</li> <li>Walk sheet generation for field teams</li> </ul>"},{"location":"v2/features/map/locations/#architecture","title":"Architecture","text":"<pre><code>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</code></pre> <p>Flow Description:</p> <ol> <li>Admin creates location \u2192 Location service validates address and optionally geocodes</li> <li>CSV import \u2192 Service parses file, detects format (standard/NAR), geocodes if needed, creates records</li> <li>NAR server import \u2192 Streams large files, joins Address+Location CSVs, converts Lambert coords, filters, bulk inserts</li> <li>Public map loads \u2192 Location service queries by bounds, returns color-coded markers</li> <li>Canvass session starts \u2192 Service loads addresses within cut polygon using ray-casting algorithm</li> <li>Geocoding \u2192 Multi-provider chain tries providers in order, caches successful results</li> </ol>"},{"location":"v2/features/map/locations/#database-models","title":"Database Models","text":""},{"location":"v2/features/map/locations/#location-model","title":"Location Model","text":"<p>See Location Model Documentation for full schema.</p> <p>Key Fields:</p> <ul> <li><code>latitude</code> / <code>longitude</code>: WGS84 coordinates (Decimal type for precision)</li> <li><code>address</code>: Street address (building level, not including unit numbers)</li> <li><code>postalCode</code>: Canadian postal code (A1A 1A1 format)</li> <li><code>province</code>: Province code (ON, QC, AB, etc.)</li> <li><code>federalDistrict</code>: Federal electoral district name</li> <li><code>buildingType</code>: SINGLE_FAMILY | MULTI_UNIT | MIXED_USE | COMMERCIAL</li> <li><code>totalUnits</code>: Number of units in building (for multi-unit buildings)</li> <li><code>geocodeConfidence</code>: Confidence score 0-100 from geocoding service</li> <li><code>geocodeProvider</code>: Google, Mapbox, Nominatim, Photon, LocationIQ, ArcGIS</li> <li><code>narLocGuid</code>: NAR LOC_GUID identifier (Canadian electoral data)</li> <li><code>buildingNotes</code>: Free-text notes about building access, parking, etc.</li> </ul> <p>NAR-Specific Fields:</p> <ul> <li><code>narLocGuid</code>: Location GUID from NAR dataset</li> <li><code>buildingUse</code>: Building use code (1=Residential, 2=Commercial, etc.)</li> <li><code>postalCode</code>: Extracted from NAR MAIL_POSTAL_CODE</li> <li><code>province</code>: Extracted from NAR PROV_CODE</li> <li><code>federalDistrict</code>: Extracted from NAR FED_ENG_NAME</li> </ul> <p>Geocoding Fields:</p> <ul> <li><code>geocodeConfidence</code>: 0-100 score (>90=high, 70-90=medium, <70=low)</li> <li><code>geocodeProvider</code>: Which provider successfully geocoded the address</li> <li><code>geocodeAttempts</code>: Number of failed geocoding attempts</li> <li><code>lastGeocodeAttempt</code>: Timestamp of last geocoding attempt</li> </ul>"},{"location":"v2/features/map/locations/#address-model","title":"Address Model","text":"<p>See Address Model Documentation for full schema.</p> <p>Key Fields:</p> <ul> <li><code>locationId</code>: Foreign key to Location (building)</li> <li><code>unitNumber</code>: Unit/apartment/suite number (optional for single-family)</li> <li><code>firstName</code> / <code>lastName</code>: Resident name</li> <li><code>email</code> / <code>phone</code>: Contact information</li> <li><code>supportLevel</code>: LEVEL_1 (Strong) | LEVEL_2 (Leaning) | LEVEL_3 (Undecided) | LEVEL_4 (Opposed)</li> <li><code>sign</code>: Boolean - has lawn/window sign</li> <li><code>signSize</code>: Sign size description (e.g., \"24x18 lawn\", \"window\")</li> <li><code>notes</code>: Free-text notes from canvassing</li> <li><code>narAddrGuid</code>: NAR ADDR_GUID identifier</li> </ul> <p>NAR-Specific Fields:</p> <ul> <li><code>narAddrGuid</code>: Address GUID from NAR dataset</li> <li><code>unitNumber</code>: Extracted from NAR APT_NO_LABEL</li> </ul> <p>Related Models:</p> <ul> <li>Cut \u2014 Polygon overlays for organizing</li> <li>CanvassVisit \u2014 Door-knock records</li> <li>LocationHistory \u2014 Audit trail</li> </ul>"},{"location":"v2/features/map/locations/#api-endpoints","title":"API Endpoints","text":"<p>See Locations Backend Module Documentation for full API reference.</p> <p>Admin Endpoints:</p> Method Endpoint Auth Description GET <code>/api/map/locations</code> MAP_ADMIN List locations with pagination, search, filters GET <code>/api/map/locations/stats</code> MAP_ADMIN Get location statistics (total, geocoded, by confidence) GET <code>/api/map/locations/:id</code> MAP_ADMIN Get location details with addresses POST <code>/api/map/locations</code> MAP_ADMIN Create new location PATCH <code>/api/map/locations/:id</code> MAP_ADMIN Update location DELETE <code>/api/map/locations/:id</code> MAP_ADMIN Delete location (and cascade addresses) POST <code>/api/map/locations/geocode</code> MAP_ADMIN Geocode single address POST <code>/api/map/locations/reverse-geocode</code> MAP_ADMIN Reverse geocode lat/lng to address POST <code>/api/map/locations/import</code> MAP_ADMIN Import CSV file (standard or NAR format) GET <code>/api/map/locations/export</code> MAP_ADMIN Export locations to CSV GET <code>/api/map/locations/:id/history</code> MAP_ADMIN Get location change history <p>Bulk Operations:</p> Method Endpoint Auth Description POST <code>/api/map/locations/bulk-geocode/start</code> MAP_ADMIN Start bulk geocoding job (BullMQ) GET <code>/api/map/locations/bulk-geocode/status</code> MAP_ADMIN Check bulk geocoding job status POST <code>/api/map/locations/bulk-geocode/cancel</code> MAP_ADMIN Cancel running bulk geocoding job <p>NAR Import Endpoints:</p> Method Endpoint Auth Description GET <code>/api/map/locations/nar/datasets</code> MAP_ADMIN List available NAR datasets from <code>/data</code> directory POST <code>/api/map/locations/nar/import</code> MAP_ADMIN Server-side streaming NAR import with filters GET <code>/api/map/locations/nar/import/progress</code> MAP_ADMIN Get NAR import progress (polling endpoint) <p>Public Endpoints:</p> Method Endpoint Auth Description GET <code>/api/public/map/locations</code> None List locations by bounds (for public map) <p>Volunteer Endpoints:</p> Method Endpoint Auth Description PATCH <code>/api/map/canvass/volunteer/locations/:id</code> 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 <code>GEOCODING_ENABLED</code> boolean <code>true</code> Enable geocoding services <code>GEOCODING_CACHE_ENABLED</code> boolean <code>true</code> Cache geocoding results in Redis <code>GEOCODING_CACHE_TTL_HOURS</code> number <code>168</code> Cache TTL (7 days) <code>GEOCODING_PROVIDERS</code> string[] See geocoding.md Comma-separated provider list <code>GOOGLE_MAPS_API_KEY</code> string - Google Geocoding API key <code>MAPBOX_ACCESS_TOKEN</code> string - Mapbox API token <code>LOCATIONIQ_API_KEY</code> string - LocationIQ API key <code>NAR_DATA_DIR</code> string <code>/data</code> Directory containing NAR CSV files"},{"location":"v2/features/map/locations/#database-indexes","title":"Database Indexes","text":"<p>Key indexes for performance:</p> <pre><code>-- 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</code></pre>"},{"location":"v2/features/map/locations/#admin-workflow","title":"Admin Workflow","text":""},{"location":"v2/features/map/locations/#creating-a-location","title":"Creating a Location","text":"<p>Step 1: Navigate to Locations Page</p> <p>Navigate to Map \u2192 Locations in the admin sidebar.</p> <p>![LocationsPage Screenshot Placeholder]</p> <p>Step 2: Click \"Add Location\"</p> <p>Click the + Add Location button in the top-right corner.</p> <p>Step 3: Enter Address Information</p> <p>Fill in the location form:</p> <ul> <li>Address: Street address (e.g., \"123 Main Street\")</li> <li>Postal Code: Canadian postal code (e.g., \"K1A 0B1\")</li> <li>Building Type: Single Family / Multi-Unit / Mixed Use / Commercial</li> <li>Total Units: Number of units (for multi-unit buildings)</li> <li>Building Notes: Access codes, parking info, etc.</li> </ul> <p>Step 4: Auto-Geocode (Optional)</p> <p>Click Geocode button to automatically fetch latitude/longitude coordinates. The system will:</p> <ol> <li>Try geocoding providers in order (Google \u2192 Mapbox \u2192 Nominatim \u2192 Photon \u2192 LocationIQ \u2192 ArcGIS)</li> <li>Return confidence score (0-100)</li> <li>Display formatted address from provider</li> <li>Cache result in Redis for 7 days</li> </ol> <p>Step 5: Add Addresses (Units)</p> <p>For multi-unit buildings, click Add Address to create unit records:</p> <ul> <li>Unit Number: Apartment/suite number</li> <li>First Name / Last Name: Resident name</li> <li>Support Level: LEVEL_1 (Strong) \u2192 LEVEL_4 (Opposed)</li> <li>Sign: Check if resident has lawn/window sign</li> <li>Notes: Canvassing notes</li> </ul> <p>Step 6: Save Location</p> <p>Click Create to save the location and addresses.</p>"},{"location":"v2/features/map/locations/#csv-import-workflow","title":"CSV Import Workflow","text":"<p>Step 1: Prepare CSV File</p> <p>Prepare a CSV file with the following columns (flexible header names):</p> <p>Standard Format:</p> <pre><code>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</code></pre> <p>NAR Format (auto-detected if 3+ NAR columns present):</p> <pre><code>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</code></pre> <p>Step 2: Open Import Modal</p> <p>Click Import CSV button on LocationsPage.</p> <p>Step 3: Select Import Format</p> <p>Choose format:</p> <ul> <li>Standard: General campaign CSV (address, firstName, lastName, supportLevel, etc.)</li> <li>NAR: National Address Register format (auto-detected)</li> <li>Server: Server-side NAR streaming import (for large files >100MB)</li> </ul> <p>Step 4: Configure Filters (Optional)</p> <p>Filter imported locations:</p> <ul> <li>Cut: Import only locations within a polygon</li> <li>Map Area: Import only locations within current map bounds</li> <li>City: Filter by city name</li> <li>Province: Filter by province code (ON, QC, AB, etc.)</li> <li>Residential Only: Exclude commercial buildings (BU_USE = 1)</li> </ul> <p>Step 5: Upload File</p> <p>Drag-and-drop or click to select CSV file.</p> <p>Step 6: Configure Geocoding</p> <p>Toggle Geocode Missing Coordinates:</p> <ul> <li>Enabled: Automatically geocode addresses without lat/lng (slower, uses geocoding API quota)</li> <li>Disabled: Import only records with coordinates (faster, for NAR imports)</li> </ul> <p>Step 7: Review Import Results</p> <p>After import completes, view results:</p> <ul> <li>Created: Number of new locations created</li> <li>Skipped: Number of duplicate addresses skipped</li> <li>Failed: Number of errors (invalid addresses, geocoding failures)</li> <li>Geocoded: Number of addresses successfully geocoded</li> </ul>"},{"location":"v2/features/map/locations/#nar-server-import-workflow","title":"NAR Server Import Workflow","text":"<p>For large NAR datasets (>100MB), use server-side streaming import:</p> <p>Step 1: Upload NAR Files to Server</p> <p>Copy NAR CSV files to server's <code>/data</code> directory:</p> <pre><code># 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</code></pre> <p>Step 2: Open NAR Import Tab</p> <p>Click NAR Import tab on LocationsPage.</p> <p>Step 3: Scan for Datasets</p> <p>Click Scan NAR Directory to detect available datasets. The system will:</p> <ul> <li>Scan <code>/data</code> directory for Address_.csv and Location_.csv files</li> <li>Group files by province code (10=NL, 24=QC, 35=ON, 48=AB, etc.)</li> <li>Display file sizes and counts</li> </ul> <p>Step 4: Select Province</p> <p>Choose province from dropdown (e.g., \"35 - Ontario (10.5 GB, 45 files)\").</p> <p>Step 5: Configure Filters</p> <p>Apply optional filters:</p> <ul> <li>City: Filter by MAIL_MUN_NAME or CSD_ENG_NAME</li> <li>Postal Code Prefix: Filter by first 3 characters (e.g., \"K1A\")</li> <li>Cut: Import only addresses within polygon</li> <li>Residential Only: Exclude commercial buildings (BU_USE != 1)</li> </ul> <p>Step 6: Start Import</p> <p>Click Start Import. The system will:</p> <ol> <li>Stream Address CSV files (multi-part files processed sequentially)</li> <li>Join with Location CSV on LOC_GUID</li> <li>Convert BG_X/BG_Y (Lambert projection) to lat/lng (WGS84) using proj4</li> <li>Apply filters (city, postal, cut, residential)</li> <li>Bulk insert locations + addresses (transaction batches of 500)</li> <li>Update progress every 5 seconds</li> </ol> <p>Step 7: Monitor Progress</p> <p>View real-time progress:</p> <ul> <li>Records Processed: Current/total count</li> <li>Progress Percentage: Visual progress bar</li> <li>ETA: Estimated time remaining</li> <li>Current File: Which multi-part file is being processed</li> </ul> <p>Step 8: Review Results</p> <p>After import completes:</p> <ul> <li>Total Created: Number of locations + addresses created</li> <li>Duration: Total import time</li> <li>Skipped: Duplicate or filtered records</li> </ul>"},{"location":"v2/features/map/locations/#bulk-re-geocoding","title":"Bulk Re-Geocoding","text":"<p>For locations with missing or low-confidence coordinates:</p> <p>Step 1: Open Bulk Geocode Modal</p> <p>Click Bulk Re-Geocode button on LocationsPage.</p> <p>Step 2: Configure Job Parameters</p> <p>Set parameters:</p> <ul> <li>Confidence Filter: Re-geocode locations below threshold (e.g., <70)</li> <li>Missing Only: Only geocode locations without coordinates</li> <li>Provider: Choose preferred geocoding provider</li> <li>Batch Size: Number of locations per batch (default: 50)</li> </ul> <p>Step 3: Start Job</p> <p>Click Start Job to queue bulk geocoding job in BullMQ.</p> <p>Step 4: Monitor Progress</p> <p>Poll job status:</p> <ul> <li>Completed: Number of successfully geocoded locations</li> <li>Failed: Number of geocoding failures</li> <li>Progress: Percentage complete</li> <li>ETA: Estimated time remaining</li> </ul> <p>Step 5: Cancel Job (Optional)</p> <p>Click Cancel Job to stop bulk geocoding.</p>"},{"location":"v2/features/map/locations/#exporting-locations","title":"Exporting Locations","text":"<p>Step 1: Configure Export Filters</p> <p>Apply filters on LocationsPage:</p> <ul> <li>Search: Filter by address or notes</li> <li>Confidence Level: High / Medium / Low / None</li> <li>Cut: Export locations within specific polygon</li> </ul> <p>Step 2: Click Export CSV</p> <p>Click Export CSV button. The system will:</p> <ol> <li>Export locations matching current filters</li> <li>Include all address records (one row per address)</li> <li>Download CSV file with timestamp</li> </ol> <p>Export Format:</p> <pre><code>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</code></pre>"},{"location":"v2/features/map/locations/#public-workflow","title":"Public Workflow","text":"<p>Public users can view locations on the interactive map.</p> <p>Step 1: Navigate to Public Map</p> <p>Visit <code>/map</code> (public route, no authentication required).</p> <p>Step 2: Browse Map</p> <p>Interact with Leaflet map:</p> <ul> <li>Zoom/Pan: Use mouse or touch gestures</li> <li>Markers: Locations displayed as color-coded circle markers:</li> <li>Green: LEVEL_1 (Strong support)</li> <li>Yellow: LEVEL_2 (Leaning support)</li> <li>Gray: LEVEL_3 (Undecided)</li> <li>Red: LEVEL_4 (Opposed)</li> <li>Blue: No support level assigned</li> </ul> <p>Step 3: View Cut Overlays</p> <p>Toggle cut overlays using Cuts control panel:</p> <ul> <li>Show/Hide: Toggle cut visibility</li> <li>Opacity: Adjust polygon transparency</li> <li>Legend: View cut color legend</li> </ul> <p>Step 4: Geolocate</p> <p>Click Geolocate button to center map on current location (requires browser geolocation permission).</p> <p>Step 5: Fullscreen Mode</p> <p>Click Fullscreen button to expand map to full screen.</p>"},{"location":"v2/features/map/locations/#volunteer-workflow","title":"Volunteer Workflow","text":"<p>Volunteers can update location data during canvassing sessions.</p> <p>Step 1: Start Canvass Session</p> <p>See Canvassing Documentation for full workflow.</p> <p>Step 2: Record Visit</p> <p>When visiting a location, update fields:</p> <ul> <li>Support Level: Update based on conversation</li> <li>Sign: Check if resident wants lawn/window sign</li> <li>Notes: Add canvassing notes</li> </ul> <p>Step 3: Update Location</p> <p>Click Save Visit to record changes. The system will:</p> <ol> <li>Create CanvassVisit record with outcome</li> <li>Update Address with new supportLevel/sign/notes</li> <li>Update Location.lastUpdated timestamp</li> <li>Create LocationHistory audit record</li> </ol>"},{"location":"v2/features/map/locations/#code-examples","title":"Code Examples","text":""},{"location":"v2/features/map/locations/#creating-a-location-frontend","title":"Creating a Location (Frontend)","text":"<pre><code>// 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</code></pre>"},{"location":"v2/features/map/locations/#geocoding-an-address-frontend","title":"Geocoding an Address (Frontend)","text":"<pre><code>// 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</code></pre>"},{"location":"v2/features/map/locations/#location-service-create-backend","title":"Location Service Create (Backend)","text":"<pre><code>// 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</code></pre>"},{"location":"v2/features/map/locations/#csv-import-detection-backend","title":"CSV Import Detection (Backend)","text":"<pre><code>// 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</code></pre>"},{"location":"v2/features/map/locations/#nar-lambert-coordinate-conversion-backend","title":"NAR Lambert Coordinate Conversion (Backend)","text":"<pre><code>// 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</code></pre>"},{"location":"v2/features/map/locations/#spatial-filtering-by-cut-backend","title":"Spatial Filtering by Cut (Backend)","text":"<pre><code>// 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</code></pre>"},{"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":"<p>Symptoms:</p> <ul> <li>\"Geocoding failed\" error message</li> <li>Location created without coordinates</li> <li>Low geocode confidence score (<50)</li> </ul> <p>Causes:</p> <ul> <li>Invalid API key for geocoding provider</li> <li>Provider quota exceeded</li> <li>Address format not recognized by provider</li> <li>Provider service down</li> </ul> <p>Solutions:</p> <ol> <li>Check API keys:</li> </ol> <pre><code># Verify API keys are set in .env\ngrep \"GOOGLE_MAPS_API_KEY\\|MAPBOX_ACCESS_TOKEN\\|LOCATIONIQ_API_KEY\" .env\n</code></pre> <ol> <li>Test geocoding endpoint directly:</li> </ol> <pre><code>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</code></pre> <ol> <li>Check provider order in env:</li> </ol> <pre><code># Try different provider order\nGEOCODING_PROVIDERS=GOOGLE,NOMINATIM,PHOTON,MAPBOX,LOCATIONIQ,ARCGIS\n</code></pre> <ol> <li>View API logs:</li> </ol> <pre><code>docker compose logs -f api | grep geocode\n</code></pre>"},{"location":"v2/features/map/locations/#issue-nar-import-fails-or-hangs","title":"Issue: NAR Import Fails or Hangs","text":"<p>Symptoms:</p> <ul> <li>NAR import progress stuck at 0%</li> <li>Import fails with \"File not found\" error</li> <li>Import fails with \"Invalid coordinates\" error</li> <li>Memory errors during large imports</li> </ul> <p>Causes:</p> <ul> <li>NAR files not in <code>/data</code> directory</li> <li>Multi-part files missing (e.g., Address_35_part_2.csv)</li> <li>Incorrect province code</li> <li>Invalid BG_X/BG_Y coordinates</li> <li>Cut polygon filter too complex</li> </ul> <p>Solutions:</p> <ol> <li>Verify NAR files exist:</li> </ol> <pre><code># 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</code></pre> <ol> <li>Check province code mapping:</li> </ol> <pre><code>10 = Newfoundland and Labrador\n24 = Quebec\n35 = Ontario\n48 = Alberta\n59 = British Columbia\n62 = Nunavut\n</code></pre> <ol> <li>Test coordinate conversion:</li> </ol> <pre><code># Verify proj4 is installed\ndocker compose exec api node -e \"const proj4 = require('proj4'); console.log(proj4.version);\"\n</code></pre> <ol> <li>Monitor import progress:</li> </ol> <pre><code># 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</code></pre> <ol> <li> <p>Use smaller filters for testing:</p> </li> <li> <p>Start with single postal code prefix (e.g., \"K1A\")</p> </li> <li>Use small cut polygon</li> <li>Enable residential-only filter (reduces records by ~50%)</li> </ol>"},{"location":"v2/features/map/locations/#issue-duplicate-locations-created-on-import","title":"Issue: Duplicate Locations Created on Import","text":"<p>Symptoms:</p> <ul> <li>Same address appears multiple times in table</li> <li>Export CSV has duplicate rows</li> <li>Location count doesn't match expected NAR count</li> </ul> <p>Causes:</p> <ul> <li>Re-importing same CSV file without checking for duplicates</li> <li>NAR Address multi-part files have overlapping records</li> <li>Different LOC_GUID for same physical address (NAR data issue)</li> </ul> <p>Solutions:</p> <ol> <li>Use NAR GUID fields for deduplication:</li> </ol> <p>The system deduplicates by <code>narLocGuid</code> and <code>narAddrGuid</code>:</p> <pre><code>// 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</code></pre> <ol> <li>Delete duplicates manually:</li> </ol> <pre><code>-- 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</code></pre> <ol> <li>Use server-side NAR import (better deduplication):</li> </ol> <p>Server-side import joins Address + Location files on LOC_GUID before inserting, preventing duplicates.</p>"},{"location":"v2/features/map/locations/#issue-low-geocode-confidence-for-nar-data","title":"Issue: Low Geocode Confidence for NAR Data","text":"<p>Symptoms:</p> <ul> <li>NAR locations have geocodeConfidence < 70</li> <li>Locations appear in wrong place on map</li> <li>\"Low confidence\" warnings in admin</li> </ul> <p>Causes:</p> <ul> <li>BG_X/BG_Y coordinates missing in NAR Location file</li> <li>BG_LATITUDE/BG_LONGITUDE used instead of converted Lambert coords</li> <li>proj4 conversion error</li> </ul> <p>Solutions:</p> <ol> <li>Verify coordinate source:</li> </ol> <p>NAR Location files have TWO coordinate fields:</p> <ul> <li><code>BG_LATITUDE</code> / <code>BG_LONGITUDE</code>: Direct WGS84 (use these if available)</li> <li> <p><code>BG_X</code> / <code>BG_Y</code>: Lambert Conformal Conic EPSG:3347 (requires conversion)</p> </li> <li> <p>Use BG_LATITUDE/BG_LONGITUDE if available:</p> </li> </ul> <pre><code>// 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</code></pre> <ol> <li>Re-geocode low-confidence locations:</li> </ol> <p>Use bulk re-geocoding feature with confidence filter <70.</p>"},{"location":"v2/features/map/locations/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/features/map/locations/#query-optimization","title":"Query Optimization","text":"<p>Bounding Box Queries:</p> <p>Always use indexed lat/lng queries for map bounds:</p> <pre><code>-- 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</code></pre> <p>Point-in-Polygon:</p> <p>For small result sets (<1000 locations), use application-level ray-casting:</p> <pre><code>// 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</code></pre> <p>For large result sets (>10,000 locations), consider PostGIS extension.</p>"},{"location":"v2/features/map/locations/#geocoding-rate-limits","title":"Geocoding Rate Limits","text":"<p>Provider Limits:</p> Provider Free Tier Rate Limit Google $200/month credit 50 req/sec Mapbox 100,000/month 600 req/min Nominatim Unlimited 1 req/sec Photon Unlimited No limit (self-hosted recommended) LocationIQ 5,000/day 2 req/sec ArcGIS 20,000/month 50 req/sec <p>Best Practices:</p> <ol> <li>Enable Redis caching (default: 7 days TTL)</li> <li>Use bulk geocoding jobs (BullMQ queue with rate limiting)</li> <li>Prefer NAR imports (coordinates included, no geocoding needed)</li> <li>Batch geocoding requests (50 locations per batch)</li> </ol>"},{"location":"v2/features/map/locations/#nar-import-performance","title":"NAR Import Performance","text":"<p>Large File Streaming:</p> <p>NAR Address files can be 10+ GB. Use server-side streaming to avoid memory issues:</p> <pre><code>// 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</code></pre> <p>Transaction Batching:</p> <p>Insert locations in transaction batches to improve performance:</p> <pre><code>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</code></pre>"},{"location":"v2/features/map/locations/#map-rendering-performance","title":"Map Rendering Performance","text":"<p>Marker Clustering:</p> <p>For maps with >1000 locations, use marker clustering to improve render performance:</p> <pre><code>// 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</code></pre> <p>Viewport Filtering:</p> <p>Only load locations within map bounds + buffer:</p> <pre><code>// 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</code></pre>"},{"location":"v2/features/map/locations/#related-documentation","title":"Related Documentation","text":"<p>Backend Modules:</p> <ul> <li>Locations Backend Module \u2014 API implementation</li> <li>Geocoding Service \u2014 Multi-provider geocoding</li> <li>Spatial Utils \u2014 Point-in-polygon algorithms</li> </ul> <p>Frontend Pages:</p> <ul> <li>LocationsPage \u2014 Admin CRUD interface</li> <li>AdminMapView \u2014 Interactive map component</li> <li>Public MapPage \u2014 Public map view</li> </ul> <p>Database:</p> <ul> <li>Map Models \u2014 Location, Address, Cut schemas</li> <li>Location History \u2014 Audit trail</li> <li>Spatial Queries \u2014 Optimization tips</li> </ul> <p>Features:</p> <ul> <li>Geocoding \u2014 Multi-provider geocoding system</li> <li>Cuts \u2014 Geographic polygon overlays</li> <li>Canvassing \u2014 Field organizing workflow</li> <li>NAR Import \u2014 Canadian electoral data import</li> <li>Data Quality Dashboard \u2014 Geocoding quality metrics</li> </ul>"},{"location":"v2/features/map/nar-import/","title":"NAR Import System","text":""},{"location":"v2/features/map/nar-import/#overview","title":"Overview","text":"<p>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.</p> <p>Key Features:</p> <ul> <li>Server-side streaming import (handles large datasets)</li> <li>NAR 2025 format support (BG_X/BG_Y Lambert projection)</li> <li>Address + Location file joining on LOC_GUID</li> <li>Proj4 coordinate conversion (EPSG:3347 \u2192 WGS84)</li> <li>Province selector (13 provinces/territories)</li> <li>Filtering: city, postal code, cut boundary, residential-only</li> <li>Multi-part file handling (large provinces)</li> <li>Progress tracking and error reporting</li> <li>Import statistics and validation</li> </ul> <p>Use Cases:</p> <ul> <li>Initial campaign database setup</li> <li>Electoral district targeting</li> <li>NAR data updates (new redistribution)</li> <li>Multi-region campaign expansion</li> <li>Address database verification</li> </ul> <p>Architecture Highlights:</p> <ul> <li>Streaming CSV parser (avoids memory limits)</li> <li>File-based LOC_GUID join</li> <li>Real-time coordinate projection</li> <li>Point-in-polygon cut filtering</li> <li>Transaction batching (500 records/commit)</li> <li>Duplicate prevention via UPSERT</li> </ul>"},{"location":"v2/features/map/nar-import/#architecture","title":"Architecture","text":"<pre><code>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</code></pre> <p>Data Flow:</p> <ol> <li>Dataset Discovery:</li> <li>Scan /data directory for NAR CSV files</li> <li>Group by province code (10-62)</li> <li>Identify multi-part Address files</li> <li> <p>Return available datasets</p> </li> <li> <p>Import Initiation:</p> </li> <li>Admin selects province + filters</li> <li>API creates import job</li> <li> <p>Begins streaming CSV files</p> </li> <li> <p>File Processing:</p> </li> <li>Read Address files (all parts sequentially)</li> <li>Read Location file (parallel)</li> <li> <p>Join on LOC_GUID (in-memory map)</p> </li> <li> <p>Coordinate Conversion:</p> </li> <li>Extract BG_X/BG_Y from Location file</li> <li>Convert EPSG:3347 \u2192 WGS84 using Proj4</li> <li> <p>Fallback to BG_LATITUDE/BG_LONGITUDE if conversion fails</p> </li> <li> <p>Filtering:</p> </li> <li>City filter (exact match on MUNICIPALITY)</li> <li>Postal code filter (prefix match)</li> <li>Cut filter (point-in-polygon)</li> <li> <p>Residential filter (BU_USE = 1)</p> </li> <li> <p>Database Import:</p> </li> <li>UPSERT Locations by locGuid (prevent duplicates)</li> <li>INSERT Addresses with foreign key</li> <li>Batch commits (500 records)</li> <li>Track progress and errors</li> </ol>"},{"location":"v2/features/map/nar-import/#nar-file-format","title":"NAR File Format","text":""},{"location":"v2/features/map/nar-import/#file-structure","title":"File Structure","text":"<p>Directory Layout: <pre><code>/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</code></pre></p>"},{"location":"v2/features/map/nar-import/#address-file-schema","title":"Address File Schema","text":"<p>File: Address_XX_part_Y.csv</p> <pre><code>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</code></pre> <p>Key Fields:</p> Field Type Description Example ADDR_GUID UUID Unique address identifier <code>{12345678-...}</code> LOC_GUID UUID Location identifier (FK) <code>{87654321-...}</code> CIVIC_NO String Street number <code>123</code>, <code>123A</code>, <code>123-125</code> OFFICIAL_STREET_NAME String Street name (uppercase) <code>MAIN ST</code>, <code>YONGE ST</code> POSTAL_CODE String Canadian postal code (no space) <code>M5H2N2</code>, <code>K1A0B1</code> MUNICIPALITY String City/town name <code>TORONTO</code>, <code>OTTAWA</code> PROVINCE_CODE Integer Province code (10-62) <code>35</code> (Ontario) <p>Record Count: - Small provinces: 10k-50k addresses - Medium provinces: 50k-200k addresses - Large provinces: 200k-1M+ addresses (multi-part files)</p>"},{"location":"v2/features/map/nar-import/#location-file-schema","title":"Location File Schema","text":"<p>File: Location_XX.csv</p> <pre><code>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</code></pre> <p>Key Fields:</p> Field Type Description Example LOC_GUID UUID Unique location identifier <code>{87654321-...}</code> BG_LATITUDE Float Latitude (WGS84) <code>43.6532</code> BG_LONGITUDE Float Longitude (WGS84) <code>-79.3832</code> BG_X Float X coord (EPSG:3347 Lambert) <code>1234567.89</code> BG_Y Float Y coord (EPSG:3347 Lambert) <code>234567.89</code> FED_NUM String Federal electoral district <code>35001</code>, <code>24050</code> BU_USE Integer Building use code <code>1</code> = Residential MUNICIPALITY String City/town name <code>TORONTO</code> <p>Coordinate Systems:</p> <ul> <li>BG_LATITUDE/BG_LONGITUDE: WGS84 decimal degrees (EPSG:4326)</li> <li>BG_X/BG_Y: Statistics Canada Lambert Conformal Conic (EPSG:3347)</li> <li>2025 NAR Change: Primary coordinates shifted from lat/lng to BG_X/BG_Y</li> </ul> <p>Building Use Codes:</p> 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":"<pre><code>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</code></pre>"},{"location":"v2/features/map/nar-import/#address-model-extensions","title":"Address Model Extensions","text":"<pre><code>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</code></pre> <p>UPSERT Strategy:</p> <pre><code>// 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</code></pre>"},{"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":"<p>Scan NAR data directory and return available province datasets.</p> <p>Authentication: Required (SUPER_ADMIN, MAP_ADMIN)</p> <p>Response: <pre><code>{\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</code></pre></p> <p>Implementation:</p> <pre><code>// 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</code></pre>"},{"location":"v2/features/map/nar-import/#post-apilocationsnarimport","title":"POST /api/locations/nar/import","text":"<p>Start NAR import job with filters.</p> <p>Authentication: Required (SUPER_ADMIN, MAP_ADMIN)</p> <p>Request Body: <pre><code>{\n \"provinceCode\": \"35\",\n \"city\": \"TORONTO\",\n \"postalCodePrefix\": \"M5\",\n \"cutId\": 42,\n \"residentialOnly\": true\n}\n</code></pre></p> <p>Parameters:</p> 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) <p>Response: <pre><code>{\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</code></pre></p>"},{"location":"v2/features/map/nar-import/#get-apilocationsnarimportjobid","title":"GET /api/locations/nar/import/:jobId","text":"<p>Check import job progress.</p> <p>Authentication: Required (SUPER_ADMIN, MAP_ADMIN)</p> <p>Response (In Progress): <pre><code>{\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</code></pre></p> <p>Response (Complete): <pre><code>{\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</code></pre></p> <p>Status Values: - <code>queued</code>: Job created, waiting to start - <code>processing</code>: Import in progress - <code>completed</code>: Import finished successfully - <code>failed</code>: Import failed with errors - <code>cancelled</code>: Import cancelled by user</p>"},{"location":"v2/features/map/nar-import/#configuration","title":"Configuration","text":""},{"location":"v2/features/map/nar-import/#environment-variables","title":"Environment Variables","text":"Variable Type Default Description NAR_DATA_DIR string /data Directory containing NAR CSV files NAR_BATCH_SIZE number 500 Records per database transaction NAR_IMPORT_TIMEOUT number 3600000 Import timeout in ms (1 hour)"},{"location":"v2/features/map/nar-import/#province-codes","title":"Province Codes","text":"<p>Complete mapping of NAR province codes:</p> <pre><code>// 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</code></pre>"},{"location":"v2/features/map/nar-import/#coordinate-projection","title":"Coordinate Projection","text":"<p>EPSG:3347 Definition (Statistics Canada Lambert Conformal Conic):</p> <pre><code>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</code></pre> <p>Projection Parameters:</p> <ul> <li>Type: Lambert Conformal Conic</li> <li>Standard Parallels: 49\u00b0N, 77\u00b0N</li> <li>Central Meridian: -91.866667\u00b0</li> <li>Origin: 63.390675\u00b0N, -91.866667\u00b0W</li> <li>False Easting: 6,200,000 m</li> <li>False Northing: 3,000,000 m</li> <li>Ellipsoid: GRS80</li> <li>Units: Meters</li> </ul> <p>Example Conversion:</p> <pre><code>// 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</code></pre>"},{"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":"<p>Step 1: Download NAR Data</p> <ol> <li>Visit Elections Canada NAR portal: https://www.elections.ca/NAR</li> <li>Select \"2025 National Address Register\"</li> <li>Download province-specific CSV files</li> <li>Extract ZIP archives</li> </ol> <p>Step 2: Upload Files to Server</p> <pre><code># 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</code></pre> <p>Step 3: Verify File Integrity</p> <pre><code># 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</code></pre>"},{"location":"v2/features/map/nar-import/#run-import-via-admin-ui","title":"Run Import via Admin UI","text":"<p>Step 1: Navigate to NAR Import Tab</p> <ol> <li>Log in as SUPER_ADMIN or MAP_ADMIN</li> <li>Click Map \u2192 Locations in sidebar</li> <li>Click NAR Import tab</li> <li>Available datasets load automatically</li> </ol> <p>Step 2: Select Province</p> <pre><code>\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</code></pre> <p>Step 3: Configure Filters (Optional)</p> <pre><code>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</code></pre> <p>Step 4: Review Import Summary</p> <pre><code>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</code></pre> <p>Step 5: Monitor Progress</p> <pre><code>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</code></pre> <p>Step 6: Review Results</p> <pre><code>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</code></pre>"},{"location":"v2/features/map/nar-import/#import-via-api","title":"Import via API","text":"<p>Step 1: Get Available Datasets</p> <pre><code>curl -X GET http://localhost:4000/api/locations/nar/datasets \\\n -H \"Authorization: Bearer $TOKEN\"\n</code></pre> <p>Step 2: Start Import</p> <pre><code>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</code></pre> <p>Step 3: Poll Job Status</p> <pre><code>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</code></pre>"},{"location":"v2/features/map/nar-import/#coordinate-conversion","title":"Coordinate Conversion","text":""},{"location":"v2/features/map/nar-import/#proj4-integration","title":"Proj4 Integration","text":"<p>Installation:</p> <pre><code>npm install proj4\n# TypeScript types included in package\n</code></pre> <p>Service Implementation:</p> <pre><code>// 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</code></pre>"},{"location":"v2/features/map/nar-import/#conversion-examples","title":"Conversion Examples","text":"<p>Example 1: Toronto City Hall</p> <pre><code>const bgX = 609091.8;\nconst bgY = 4834610.7;\n\nconst coords = convertCoordinates(bgX, bgY);\n// Result: { latitude: 43.6532, longitude: -79.3832 }\n</code></pre> <p>Example 2: Parliament Hill, Ottawa</p> <pre><code>const bgX = 447384.4;\nconst bgY = 5030660.5;\n\nconst coords = convertCoordinates(bgX, bgY);\n// Result: { latitude: 45.4236, longitude: -75.7009 }\n</code></pre> <p>Example 3: Invalid Coordinates</p> <pre><code>const bgX = -1000; // Negative (invalid)\nconst bgY = 0; // Zero (invalid)\n\nconst coords = convertCoordinates(bgX, bgY);\n// Result: null\n</code></pre>"},{"location":"v2/features/map/nar-import/#validation","title":"Validation","text":"<p>Canada Bounds Check:</p> <pre><code>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</code></pre> <p>Precision Check:</p> <pre><code>// 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</code></pre>"},{"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":"<p>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</p> <p>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</p>"},{"location":"v2/features/map/nar-import/#sequential-file-reading","title":"Sequential File Reading","text":"<pre><code>// 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</code></pre>"},{"location":"v2/features/map/nar-import/#memory-management","title":"Memory Management","text":"<p>Streaming Strategy:</p> <pre><code>// 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</code></pre> <p>Batch Transaction:</p> <pre><code>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</code></pre>"},{"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":"<pre><code>// 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</code></pre>"},{"location":"v2/features/map/nar-import/#nar-import-service-full-implementation","title":"NAR Import Service - Full Implementation","text":"<pre><code>// 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</code></pre>"},{"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":"<p>Symptoms: - GET /api/locations/nar/datasets returns empty array - \"No datasets available\" message in admin</p> <p>Solutions:</p> <ol> <li> <p>Verify NAR_DATA_DIR path: <pre><code>echo $NAR_DATA_DIR\nls -la /data\n</code></pre></p> </li> <li> <p>Check Docker volume mount: <pre><code># docker-compose.yml\nservices:\n api:\n volumes:\n - ./data:/data:ro\n</code></pre></p> </li> <li> <p>Verify file naming convention: <pre><code># 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</code></pre></p> </li> <li> <p>Check file permissions: <pre><code>chmod 644 /data/Address_*.csv\nchmod 644 /data/Location_*.csv\n</code></pre></p> </li> </ol>"},{"location":"v2/features/map/nar-import/#problem-coordinate-conversion-errors","title":"Problem: Coordinate conversion errors","text":"<p>Symptoms: - Many locations skipped during import - \"Converted coordinates outside Canada\" warnings - Null latitude/longitude in database</p> <p>Solutions:</p> <ol> <li> <p>Verify BG_X/BG_Y values: <pre><code>// 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</code></pre></p> </li> <li> <p>Test with known coordinates: <pre><code>// 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</code></pre></p> </li> <li> <p>Fallback to BG_LATITUDE/BG_LONGITUDE: <pre><code>// 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</code></pre></p> </li> <li> <p>Check proj4 definition: <pre><code>npm list proj4\n# Ensure version 2.8.0+\n</code></pre></p> </li> </ol>"},{"location":"v2/features/map/nar-import/#problem-import-very-slow-30min-for-100k-records","title":"Problem: Import very slow (> 30min for 100k records)","text":"<p>Symptoms: - Import hangs on large provinces - Memory usage grows over time - Database connection timeouts</p> <p>Solutions:</p> <ol> <li> <p>Increase batch size: <pre><code>NAR_BATCH_SIZE=1000 # Default: 500\n</code></pre></p> </li> <li> <p>Use streaming instead of loading all addresses: <pre><code>// 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</code></pre></p> </li> <li> <p>Optimize database indexes: <pre><code>CREATE INDEX CONCURRENTLY idx_locations_loc_guid ON \"Location\"(locGuid);\nCREATE INDEX CONCURRENTLY idx_addresses_addr_guid ON \"Address\"(addrGuid);\n</code></pre></p> </li> <li> <p>Disable geocoding during import: <pre><code>// Skip geocoding service since NAR already has coordinates\ngeocodeConfidence: 100,\ngeocodeProvider: 'NAR'\n// No call to geocodingService.geocode()\n</code></pre></p> </li> <li> <p>Use worker threads for parallel processing: <pre><code>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</code></pre></p> </li> </ol>"},{"location":"v2/features/map/nar-import/#problem-duplicate-loc_guid-errors","title":"Problem: Duplicate LOC_GUID errors","text":"<p>Symptoms: - Unique constraint violation on locGuid - Import fails mid-process - \"Duplicate key value violates unique constraint\" error</p> <p>Solutions:</p> <ol> <li> <p>Use UPSERT instead of INSERT: <pre><code>await prisma.location.upsert({\n where: { locGuid: narRecord.LOC_GUID },\n update: { /* update fields */ },\n create: { /* create fields */ }\n});\n</code></pre></p> </li> <li> <p>Check for corrupt NAR files: <pre><code># 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</code></pre></p> </li> <li> <p>Clean up partial imports: <pre><code>-- Delete locations from failed import\nDELETE FROM \"Location\" WHERE \"geocodeProvider\" = 'NAR' AND \"createdAt\" > '2025-02-13';\n</code></pre></p> </li> <li> <p>Implement transaction rollback on error: <pre><code>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</code></pre></p> </li> </ol>"},{"location":"v2/features/map/nar-import/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/features/map/nar-import/#import-speed","title":"Import Speed","text":"<p>Benchmarks:</p> Province Records Files Time Records/Second PEI (11) 15,000 1 12s 1,250 Nova Scotia (12) 85,000 1 1m 10s 1,214 Quebec (24) 850,000 6 11m 20s 1,250 Ontario (35) 1,200,000 3 14m 30s 1,379 <p>Factors: - Batch size: 500 (optimal for most systems) - Coordinate conversion: ~0.1ms per record - Database write: ~0.5ms per location (depends on disk speed) - Total overhead: ~0.7ms per record</p>"},{"location":"v2/features/map/nar-import/#memory-usage","title":"Memory Usage","text":"<p>Peak Memory: - Address map (in-memory): ~200MB per 100k records - CSV parser buffer: ~10MB - Batch buffer: ~5MB (500 records) - Total: ~220MB per 100k records</p> <p>Optimization: - Stream address files instead of loading all - Process location file in chunks - Clear batch after each commit - Limit concurrent transactions</p>"},{"location":"v2/features/map/nar-import/#database-load","title":"Database Load","text":"<p>Transaction Rate: - 1 transaction per batch (500 records) - ~2-3 transactions/second - Low database CPU (~10-20%) - Moderate disk I/O (sequential writes)</p> <p>Connection Pool: <pre><code>// prisma/schema.prisma\ndatasource db {\n url = env(\"DATABASE_URL\")\n connection_limit = 10\n}\n</code></pre></p>"},{"location":"v2/features/map/nar-import/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/features/map/nar-import/#backend-documentation","title":"Backend Documentation","text":"<ul> <li>NAR Import Service: <code>api/src/modules/map/locations/nar-import.service.ts</code></li> <li>File scanning</li> <li>Streaming CSV parser</li> <li>Coordinate conversion</li> <li> <p>Batch import</p> </li> <li> <p>NAR Import Routes: <code>api/src/modules/map/locations/nar-import.routes.ts</code></p> </li> <li>Dataset discovery</li> <li>Import job creation</li> <li> <p>Progress tracking</p> </li> <li> <p>Locations Service: <code>api/src/modules/map/locations/locations.service.ts</code></p> </li> <li>Location CRUD</li> <li>Geocoding integration</li> </ul>"},{"location":"v2/features/map/nar-import/#frontend-documentation","title":"Frontend Documentation","text":"<ul> <li>Locations Page: <code>admin/src/pages/LocationsPage.tsx</code></li> <li>NAR Import tab</li> <li>Dataset selection</li> <li>Filter configuration</li> <li>Progress monitoring</li> </ul>"},{"location":"v2/features/map/nar-import/#database-documentation","title":"Database Documentation","text":"<ul> <li>Location Model: <code>api/prisma/schema.prisma</code></li> <li>NAR-specific fields</li> <li>locGuid unique constraint</li> <li> <p>Federal district index</p> </li> <li> <p>Address Model: <code>api/prisma/schema.prisma</code></p> </li> <li>addrGuid unique constraint</li> <li>Location foreign key</li> </ul>"},{"location":"v2/features/map/nar-import/#external-resources","title":"External Resources","text":"<ul> <li>Elections Canada NAR: https://www.elections.ca/content.aspx?section=res&dir=cir/tech/nar&document=index&lang=e</li> <li>EPSG:3347 Definition: https://epsg.io/3347</li> <li>Proj4 Documentation: https://github.com/proj4js/proj4js</li> <li>NAR Data Dictionary: Elections Canada NAR Technical Documentation (PDF)</li> </ul>"},{"location":"v2/features/map/shifts/","title":"Volunteer Shift Management","text":""},{"location":"v2/features/map/shifts/#overview","title":"Overview","text":"<p>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.</p> <p>Key Capabilities:</p> <ul> <li>Shift Scheduling: Date, start/end times (HH:MM format), location</li> <li>Capacity Management: Max volunteers, auto-status updates (OPEN \u2192 FULL)</li> <li>Cut Assignment: Link shifts to geographic cuts for territory-based organizing</li> <li>Public Signup: Unauthenticated users can signup (creates TEMP user)</li> <li>Email Confirmations: Auto-send confirmation emails on signup</li> <li>Signup Tracking: Source tracking (AUTHENTICATED, PUBLIC, ADMIN)</li> <li>Status Lifecycle: OPEN, FULL, CANCELLED workflow</li> <li>Bulk Operations: Email all volunteers, export signups CSV</li> </ul> <p>Use Cases:</p> <ul> <li>Canvassing shift scheduling</li> <li>Phone bank volunteer coordination</li> <li>Event volunteer management</li> <li>Door-knocking territory assignment</li> <li>Get-out-the-vote (GOTV) shifts</li> <li>Public volunteer recruitment</li> <li>Volunteer confirmation emails</li> </ul>"},{"location":"v2/features/map/shifts/#architecture","title":"Architecture","text":"<pre><code>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</code></pre> <p>Flow Description:</p> <ol> <li>Admin creates shift \u2192 Validates date/time, assigns cut (optional), saves to database</li> <li>Public user browses \u2192 Query upcoming shifts (isPublic=true, date >=today), display cards</li> <li>Public signup \u2192 Check capacity, create TEMP user if unauthenticated, create signup record, send confirmation email</li> <li>Volunteer views assignments \u2192 Query signups for current user, include shift + cut details</li> <li>Shift capacity check \u2192 Auto-update status to FULL when currentVolunteers >= maxVolunteers</li> </ol>"},{"location":"v2/features/map/shifts/#database-models","title":"Database Models","text":""},{"location":"v2/features/map/shifts/#shift-model","title":"Shift Model","text":"<p>See Shift Model Documentation for full schema.</p> <p>Key Fields:</p> <ul> <li><code>title</code>: Shift name (e.g., \"Saturday Canvassing - Downtown\")</li> <li><code>description</code>: Free-text shift details</li> <li><code>date</code>: Shift date (Date type, not DateTime)</li> <li><code>startTime</code>: Start time in HH:MM format (24-hour)</li> <li><code>endTime</code>: End time in HH:MM format (24-hour)</li> <li><code>location</code>: Meeting point address/description</li> <li><code>maxVolunteers</code>: Maximum volunteer capacity</li> <li><code>currentVolunteers</code>: Current signup count (auto-updated)</li> <li><code>status</code>: OPEN | FULL | CANCELLED</li> <li><code>isPublic</code>: Show on public shifts page</li> <li><code>cutId</code>: Optional foreign key to Cut (territory assignment)</li> <li><code>createdBy</code>: User ID who created shift</li> </ul> <p>Status Enum:</p> <pre><code>enum ShiftStatus {\n OPEN // Accepting signups\n FULL // At capacity\n CANCELLED // Cancelled by admin\n}\n</code></pre>"},{"location":"v2/features/map/shifts/#shiftsignup-model","title":"ShiftSignup Model","text":"<p>See ShiftSignup Model Documentation for full schema.</p> <p>Key Fields:</p> <ul> <li><code>shiftId</code>: Foreign key to Shift</li> <li><code>userId</code>: Foreign key to User (optional for TEMP users)</li> <li><code>userEmail</code>: Email address (required, used for confirmations)</li> <li><code>userName</code>: Display name</li> <li><code>userPhone</code>: Phone number (optional)</li> <li><code>status</code>: CONFIRMED | CANCELLED | NO_SHOW</li> <li><code>signupDate</code>: When signup occurred</li> <li><code>signupSource</code>: AUTHENTICATED | PUBLIC | ADMIN</li> <li><code>notes</code>: Admin notes about signup</li> </ul> <p>Signup Source Enum:</p> <pre><code>enum SignupSource {\n AUTHENTICATED // Logged-in user signup\n PUBLIC // Public signup (creates TEMP user)\n ADMIN // Admin created signup\n}\n</code></pre> <p>Signup Status Enum:</p> <pre><code>enum SignupStatus {\n CONFIRMED // Signup active\n CANCELLED // Volunteer cancelled\n NO_SHOW // Marked as no-show by admin\n}\n</code></pre> <p>Related Models:</p> <ul> <li>Shift \u2014 Parent shift</li> <li>User \u2014 Volunteer account (TEMP role for public signups)</li> <li>Cut \u2014 Geographic territory assignment</li> <li>CanvassSession \u2014 Linked to shift for canvassing</li> </ul>"},{"location":"v2/features/map/shifts/#api-endpoints","title":"API Endpoints","text":"<p>See Shifts Backend Module Documentation for full API reference.</p> <p>Admin Endpoints:</p> Method Endpoint Auth Description GET <code>/api/map/shifts</code> MAP_ADMIN List shifts with pagination, search, filters GET <code>/api/map/shifts/stats</code> MAP_ADMIN Get shift statistics (total, upcoming, by status) GET <code>/api/map/shifts/:id</code> MAP_ADMIN Get shift details with signups POST <code>/api/map/shifts</code> MAP_ADMIN Create new shift PATCH <code>/api/map/shifts/:id</code> MAP_ADMIN Update shift DELETE <code>/api/map/shifts/:id</code> MAP_ADMIN Delete shift (cascade signups) POST <code>/api/map/shifts/:id/signups</code> MAP_ADMIN Manually add signup PATCH <code>/api/map/shifts/:id/signups/:signupId</code> MAP_ADMIN Update signup (change status, notes) DELETE <code>/api/map/shifts/:id/signups/:signupId</code> MAP_ADMIN Delete signup POST <code>/api/map/shifts/:id/email-volunteers</code> MAP_ADMIN Send email to all shift volunteers <p>Public Endpoints:</p> Method Endpoint Auth Description GET <code>/api/public/map/shifts</code> None List upcoming public shifts (isPublic=true, date >=today) GET <code>/api/public/map/shifts/:id</code> None Get public shift details POST <code>/api/public/map/shifts/:id/signup</code> None Public signup (creates TEMP user if unauthenticated) <p>Volunteer Endpoints:</p> Method Endpoint Auth Description GET <code>/api/map/canvass/volunteer/assignments</code> Any logged-in user Get shifts user signed up for DELETE <code>/api/map/shifts/:id/signups/cancel</code> 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 <code>EMAIL_TEST_MODE</code> boolean <code>false</code> Send confirmation emails to MailHog (dev) <code>SMTP_HOST</code> string - SMTP server for confirmation emails <code>SMTP_PORT</code> number <code>587</code> SMTP port <code>SMTP_USER</code> string - SMTP username <code>SMTP_PASSWORD</code> string - SMTP password"},{"location":"v2/features/map/shifts/#email-templates","title":"Email Templates","text":"<p>Shift Confirmation Email:</p> <p>Subject: <code>Shift Confirmation - {{shift.title}}</code></p> <p>Body: <pre><code>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</code></pre></p> <p>Admin Email All Volunteers:</p> <p>Subject: Configurable by admin</p> <p>Body: Configurable by admin (supports {{name}}, {{email}}, {{phone}} placeholders)</p>"},{"location":"v2/features/map/shifts/#admin-workflow","title":"Admin Workflow","text":""},{"location":"v2/features/map/shifts/#creating-a-shift","title":"Creating a Shift","text":"<p>Step 1: Navigate to Shifts Page</p> <p>Navigate to Map \u2192 Shifts in the admin sidebar.</p> <p>![ShiftsPage Screenshot Placeholder]</p> <p>Step 2: Click \"Add Shift\"</p> <p>Click + Add Shift button in the top-right corner.</p> <p>Step 3: Fill Shift Form</p> <p>Complete shift details:</p> <ul> <li>Title: \"Saturday Canvassing - Ward 5\"</li> <li>Description: \"Door-knocking downtown, meet at campaign office\"</li> <li>Date: Select date from calendar</li> <li>Start Time: \"09:00\" (24-hour format)</li> <li>End Time: \"12:00\"</li> <li>Location: \"123 Campaign Office, Main St\"</li> <li>Max Volunteers: 20</li> <li>Cut: Select from dropdown (optional)</li> <li>Is Public: Toggle to show on public shifts page</li> </ul> <p>Step 4: Save Shift</p> <p>Click Create to save shift. Status is automatically set to OPEN.</p>"},{"location":"v2/features/map/shifts/#managing-signups","title":"Managing Signups","text":"<p>Step 1: View Shift</p> <p>Click Signups button on a shift row to open signups drawer.</p> <p>Step 2: View Signup List</p> <p>Drawer displays:</p> <ul> <li>Volunteer Name: From signup or user account</li> <li>Email: Contact email</li> <li>Phone: Contact phone (if provided)</li> <li>Signup Date: When volunteer signed up</li> <li>Source: AUTHENTICATED | PUBLIC | ADMIN</li> <li>Status: CONFIRMED | CANCELLED | NO_SHOW</li> </ul> <p>Step 3: Manually Add Signup (Admin)</p> <p>Click Add Signup button in drawer:</p> <ul> <li>Email: Required (validates format)</li> <li>Name: Required</li> <li>Phone: Optional</li> <li>Notes: Admin notes</li> </ul> <p>System will:</p> <ol> <li>Check capacity (reject if FULL)</li> <li>Create TEMP user if email not in database</li> <li>Create signup with source=ADMIN</li> <li>Send confirmation email</li> <li>Update shift.currentVolunteers count</li> </ol> <p>Step 4: Mark No-Show</p> <p>Click Mark No-Show on signup row to update status. Useful for tracking volunteer reliability.</p> <p>Step 5: Delete Signup</p> <p>Click Delete to remove signup. Decrements shift.currentVolunteers count.</p>"},{"location":"v2/features/map/shifts/#emailing-all-volunteers","title":"Emailing All Volunteers","text":"<p>Step 1: Click \"Email All\"</p> <p>On shift row, click Email All button.</p> <p>Step 2: Compose Email</p> <p>Modal opens with:</p> <ul> <li>Subject: Pre-filled with shift title</li> <li>Message: Rich text editor with placeholders</li> <li>Placeholders: {{name}}, {{email}}, {{phone}}, {{shift.title}}, {{shift.date}}, {{shift.startTime}}, {{shift.endTime}}</li> </ul> <p>Step 3: Preview</p> <p>Click Preview to see sample email with placeholders replaced.</p> <p>Step 4: Send</p> <p>Click Send Email to queue emails to all CONFIRMED volunteers. Uses BullMQ email queue for async processing.</p>"},{"location":"v2/features/map/shifts/#updating-shift-status","title":"Updating Shift Status","text":"<p>Step 1: Edit Shift</p> <p>Click Edit on shift row.</p> <p>Step 2: Change Status</p> <p>Update status dropdown:</p> <ul> <li>OPEN: Accepting signups</li> <li>FULL: At capacity (auto-set when currentVolunteers >= maxVolunteers)</li> <li>CANCELLED: Cancelled by admin</li> </ul> <p>Step 3: Save</p> <p>Click Update. If status changed to CANCELLED, optionally send cancellation email to all volunteers.</p>"},{"location":"v2/features/map/shifts/#public-workflow","title":"Public Workflow","text":"<p>Public users can browse and signup for shifts without authentication.</p> <p>Step 1: Navigate to Public Shifts Page</p> <p>Visit <code>/shifts</code> (public route, no auth required).</p> <p>Step 2: Browse Shifts</p> <p>View upcoming shifts as cards:</p> <ul> <li>Shift Title: Large heading</li> <li>Date/Time: Formatted date + time range</li> <li>Location: Meeting point</li> <li>Volunteers: \"5 / 20 spots filled\" progress bar</li> <li>Cut: Territory name (if assigned)</li> <li>Status Badge: OPEN (green), FULL (red), CANCELLED (gray)</li> </ul> <p>Step 3: Filter Shifts</p> <p>Use filters:</p> <ul> <li>Date: Show only shifts on specific date</li> <li>Status: OPEN only (hide FULL/CANCELLED)</li> </ul> <p>Step 4: Click Signup</p> <p>Click Signup button on shift card. Modal opens.</p> <p>Step 5: Fill Signup Form</p> <p>Complete form:</p> <ul> <li>Name: Required</li> <li>Email: Required (validates format)</li> <li>Phone: Optional</li> </ul> <p>Step 6: Submit</p> <p>Click Sign Up. System will:</p> <ol> <li>Check capacity (reject if FULL)</li> <li>Create TEMP user with email (if not exists)</li> <li>Create shift signup with source=PUBLIC</li> <li>Send confirmation email</li> <li>Update shift.currentVolunteers count</li> <li>Auto-update status to FULL if at capacity</li> </ol> <p>Step 7: Receive Confirmation</p> <p>Check email for confirmation with shift details.</p>"},{"location":"v2/features/map/shifts/#volunteer-workflow","title":"Volunteer Workflow","text":"<p>Authenticated volunteers can view assigned shifts and cancel signups.</p> <p>Step 1: Login</p> <p>Login at <code>/login</code> with volunteer account.</p> <p>Step 2: Navigate to Assignments</p> <p>Navigate to Volunteer \u2192 My Assignments.</p> <p>Step 3: View Assigned Shifts</p> <p>Table displays:</p> <ul> <li>Shift Title: Linked to shift details</li> <li>Date/Time: Formatted</li> <li>Location: Meeting point</li> <li>Cut: Territory name (if assigned)</li> <li>Status: Signup status</li> </ul> <p>Step 4: View Shift Details</p> <p>Click shift title to view:</p> <ul> <li>Description: Full shift details</li> <li>Volunteers: List of other volunteers (names only, privacy protected)</li> <li>Map: If cut assigned, show cut polygon on map</li> </ul> <p>Step 5: Cancel Signup</p> <p>Click Cancel Signup button. Confirmation modal appears.</p> <p>Step 6: Confirm Cancellation</p> <p>Click Confirm. System will:</p> <ol> <li>Update signup status to CANCELLED</li> <li>Decrement shift.currentVolunteers count</li> <li>Update shift status to OPEN if was FULL</li> <li>Send cancellation confirmation email</li> </ol>"},{"location":"v2/features/map/shifts/#code-examples","title":"Code Examples","text":""},{"location":"v2/features/map/shifts/#shift-service-create-backend","title":"Shift Service Create (Backend)","text":"<pre><code>// 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</code></pre>"},{"location":"v2/features/map/shifts/#public-signup-backend","title":"Public Signup (Backend)","text":"<pre><code>// 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</code></pre>"},{"location":"v2/features/map/shifts/#generate-readable-password-backend","title":"Generate Readable Password (Backend)","text":"<pre><code>// 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</code></pre>"},{"location":"v2/features/map/shifts/#shift-confirmation-email-backend","title":"Shift Confirmation Email (Backend)","text":"<pre><code>// 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</code></pre>"},{"location":"v2/features/map/shifts/#public-shifts-list-frontend","title":"Public Shifts List (Frontend)","text":"<pre><code>// 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</code></pre>"},{"location":"v2/features/map/shifts/#signup-modal-frontend","title":"Signup Modal (Frontend)","text":"<pre><code>// 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</code></pre>"},{"location":"v2/features/map/shifts/#volunteer-assignments-frontend","title":"Volunteer Assignments (Frontend)","text":"<pre><code>// 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</code></pre>"},{"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":"<p>Symptoms:</p> <ul> <li>Shift accepts signups beyond maxVolunteers</li> <li>Status remains OPEN even when at capacity</li> <li>currentVolunteers count incorrect</li> </ul> <p>Causes:</p> <ul> <li>currentVolunteers not incremented on signup</li> <li>Signup deletion not decrementing count</li> <li>Race condition on concurrent signups</li> </ul> <p>Solutions:</p> <ol> <li>Use database transaction for capacity check + signup creation:</li> </ol> <pre><code>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</code></pre> <ol> <li>Verify count matches reality:</li> </ol> <pre><code>-- 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</code></pre> <ol> <li>Recalculate counts:</li> </ol> <pre><code>// 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</code></pre>"},{"location":"v2/features/map/shifts/#issue-confirmation-emails-not-sending","title":"Issue: Confirmation Emails Not Sending","text":"<p>Symptoms:</p> <ul> <li>Users signup successfully but no email received</li> <li>MailHog shows no emails in dev</li> <li>SMTP errors in API logs</li> </ul> <p>Causes:</p> <ul> <li>EMAIL_TEST_MODE not set in dev</li> <li>SMTP credentials invalid</li> <li>Email service not configured</li> <li>Email in spam folder</li> </ul> <p>Solutions:</p> <ol> <li>Check email service config:</li> </ol> <pre><code># 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</code></pre> <ol> <li>Test email service:</li> </ol> <pre><code># 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</code></pre> <ol> <li>Check MailHog in dev:</li> </ol> <pre><code># 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</code></pre> <ol> <li>Check spam folder (production):</li> </ol> <p>Add SPF/DKIM/DMARC records to domain to improve deliverability.</p>"},{"location":"v2/features/map/shifts/#issue-temp-user-password-security","title":"Issue: TEMP User Password Security","text":"<p>Symptoms:</p> <ul> <li>TEMP users can't login with generated password</li> <li>Password doesn't meet complexity requirements</li> <li>Account locked after signup</li> </ul> <p>Causes:</p> <ul> <li>Generated password doesn't meet 12-char minimum</li> <li>Password missing uppercase/lowercase/digit</li> <li>Password not sent to user (they can't login)</li> </ul> <p>Solutions:</p> <ol> <li>Ensure generated password meets policy:</li> </ol> <pre><code>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</code></pre> <ol> <li>Send password to user (security risk, consider alternative):</li> </ol> <p>Include password in confirmation email (only for TEMP users, one-time):</p> <pre><code>Your temporary account has been created.\n\nEmail: {{email}}\nPassword: {{password}}\n\nPlease change your password after logging in.\n</code></pre> <p>Better Alternative: Use passwordless login link:</p> <pre><code>Click here to confirm your shift and access your account:\nhttps://app.cmlite.org/confirm-shift/{{signupToken}}\n</code></pre>"},{"location":"v2/features/map/shifts/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/features/map/shifts/#shift-query-optimization","title":"Shift Query Optimization","text":"<p>Index Upcoming Shifts:</p> <p>Create composite index for common query:</p> <pre><code>CREATE INDEX idx_shifts_upcoming ON \"Shift\" (date, \"isPublic\", status)\nWHERE date >= CURRENT_DATE;\n</code></pre> <p>Efficient Public Query:</p> <pre><code>// 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</code></pre>"},{"location":"v2/features/map/shifts/#email-queue-performance","title":"Email Queue Performance","text":"<p>Batch Email Sending:</p> <p>Use BullMQ queue to avoid blocking API requests:</p> <pre><code>// 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</code></pre>"},{"location":"v2/features/map/shifts/#concurrent-signup-handling","title":"Concurrent Signup Handling","text":"<p>Prevent Race Conditions:</p> <p>Use database transactions with <code>SELECT FOR UPDATE</code>:</p> <pre><code>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</code></pre>"},{"location":"v2/features/map/shifts/#related-documentation","title":"Related Documentation","text":"<p>Backend Modules:</p> <ul> <li>Shifts Backend Module \u2014 API implementation</li> <li>Email Service \u2014 Confirmation emails</li> </ul> <p>Frontend Pages:</p> <ul> <li>ShiftsPage \u2014 Admin CRUD interface</li> <li>Public ShiftsPage \u2014 Public signup</li> <li>VolunteerShiftsPage \u2014 Volunteer assignments</li> </ul> <p>Database:</p> <ul> <li>Shift Model \u2014 Shift schema</li> <li>ShiftSignup Model \u2014 Signup records</li> <li>User Model \u2014 TEMP user accounts</li> </ul> <p>Features:</p> <ul> <li>Cuts \u2014 Territory assignment for shifts</li> <li>Canvassing \u2014 Shift-based canvassing sessions</li> <li>Users \u2014 TEMP user management</li> </ul>"},{"location":"v2/features/map/tracking/","title":"GPS Tracking System","text":""},{"location":"v2/features/map/tracking/#overview","title":"Overview","text":"<p>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.</p> <p>Key Capabilities:</p> <ul> <li>Live Tracking: Real-time volunteer GPS positions</li> <li>Breadcrumb Trails: Auto-record GPS points every 10 seconds</li> <li>Distance Calculation: Haversine formula for accurate walking distance</li> <li>Event Markers: Mark key events (session start, visits, session end)</li> <li>Route Visualization: Leaflet polyline with color-coded event markers</li> <li>1:1 Canvass Link: Each TrackingSession linked to one CanvassSession</li> <li>Admin Oversight: View live volunteer positions on map</li> <li>Privacy Controls: Tracking only during active canvass sessions</li> </ul>"},{"location":"v2/features/map/tracking/#architecture","title":"Architecture","text":"<pre><code>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</code></pre> <p>Flow Description:</p> <ol> <li>Canvass session starts \u2192 Create TrackingSession linked 1:1</li> <li>GPS auto-tracking \u2192 watchPosition submits points every 10s</li> <li>Distance calculation \u2192 Haversine formula calculates incremental distance</li> <li>Event markers \u2192 Mark visits, session start/end with eventType</li> <li>Admin oversight \u2192 View live volunteer positions on dashboard</li> <li>Route history \u2192 Generate polyline from saved TrackPoints</li> </ol>"},{"location":"v2/features/map/tracking/#database-models","title":"Database Models","text":""},{"location":"v2/features/map/tracking/#trackingsession-model","title":"TrackingSession Model","text":"<p>See TrackingSession Model Documentation.</p> <p>Key Fields:</p> <ul> <li><code>userId</code>: Foreign key to volunteer User</li> <li><code>canvassSessionId</code>: 1:1 foreign key to CanvassSession</li> <li><code>startedAt</code>: Tracking start timestamp</li> <li><code>endedAt</code>: Tracking end timestamp (null while active)</li> <li><code>isActive</code>: Boolean - tracking currently running</li> <li><code>totalPoints</code>: Count of TrackPoint records</li> <li><code>totalDistanceM</code>: Total distance walked in meters</li> <li><code>lastLatitude</code> / <code>lastLongitude</code>: Most recent GPS position</li> <li><code>lastRecordedAt</code>: Timestamp of last GPS point</li> </ul>"},{"location":"v2/features/map/tracking/#trackpoint-model","title":"TrackPoint Model","text":"<p>See TrackPoint Model Documentation.</p> <p>Key Fields:</p> <ul> <li><code>trackingSessionId</code>: Foreign key to TrackingSession</li> <li><code>latitude</code> / <code>longitude</code>: GPS coordinates (Decimal type)</li> <li><code>accuracy</code>: GPS accuracy in meters (lower = better)</li> <li><code>recordedAt</code>: When point was recorded (client timestamp)</li> <li><code>eventType</code>: Optional event marker (LOCATION_ADDED, VISIT_RECORDED, SESSION_STARTED, SESSION_ENDED)</li> </ul> <p>Event Type Enum:</p> <pre><code>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</code></pre>"},{"location":"v2/features/map/tracking/#api-endpoints","title":"API Endpoints","text":"<p>See Tracking Backend Module Documentation.</p> <p>Volunteer Endpoints:</p> Method Endpoint Auth Description POST <code>/api/map/tracking/sessions</code> Any logged-in user Start tracking session PATCH <code>/api/map/tracking/sessions/:id/end</code> Any logged-in user End tracking session POST <code>/api/map/tracking/sessions/:id/points</code> Any logged-in user Submit batch of GPS points GET <code>/api/map/tracking/sessions/:id</code> Any logged-in user Get tracking session details GET <code>/api/map/tracking/sessions/:id/route</code> Any logged-in user Get route polyline (all points) <p>Admin Endpoints:</p> Method Endpoint Auth Description GET <code>/api/map/tracking/admin/live</code> MAP_ADMIN Get live volunteer positions GET <code>/api/map/tracking/admin/sessions/:id</code> MAP_ADMIN Get volunteer tracking session GET <code>/api/map/tracking/admin/sessions/:id/route</code> 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 <code>SUBMIT_INTERVAL_MS</code> <code>10000</code> Submit GPS points every 10 seconds <code>MAX_DISTANCE_JUMP_M</code> <code>1000</code> Ignore GPS glitches >1km distance <code>HIGH_ACCURACY</code> <code>true</code> Use GPS + WiFi + cellular (vs WiFi only) <code>MAX_AGE_MS</code> <code>0</code> Don't use cached GPS position <code>TIMEOUT_MS</code> <code>10000</code> GPS position timeout (10s)"},{"location":"v2/features/map/tracking/#privacy-security","title":"Privacy & Security","text":"<ul> <li>Opt-In Only: Tracking only enabled when volunteer starts canvass session</li> <li>Session-Based: Tracking ends when session ends (not continuous)</li> <li>Admin-Only: Only MAP_ADMIN can view live positions</li> <li>Data Retention: TrackPoints retained for analytics (consider GDPR compliance for EU campaigns)</li> </ul>"},{"location":"v2/features/map/tracking/#code-examples","title":"Code Examples","text":""},{"location":"v2/features/map/tracking/#start-tracking-session-backend","title":"Start Tracking Session (Backend)","text":"<pre><code>// 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</code></pre>"},{"location":"v2/features/map/tracking/#submit-gps-points-backend","title":"Submit GPS Points (Backend)","text":"<pre><code>// 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</code></pre>"},{"location":"v2/features/map/tracking/#gps-auto-tracking-frontend","title":"GPS Auto-Tracking (Frontend)","text":"<pre><code>// 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</code></pre>"},{"location":"v2/features/map/tracking/#route-visualization-frontend","title":"Route Visualization (Frontend)","text":"<pre><code>// 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</code></pre>"},{"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":"<p>Solutions:</p> <ol> <li>Reduce accuracy: <code>enableHighAccuracy: false</code></li> <li>Increase submit interval: <code>SUBMIT_INTERVAL_MS = 30000</code> (30s)</li> <li>Add pause/resume tracking buttons</li> </ol>"},{"location":"v2/features/map/tracking/#issue-distance-calculation-incorrect","title":"Issue: Distance Calculation Incorrect","text":"<p>Symptoms: Total distance much higher than expected</p> <p>Causes: GPS glitches causing large jumps</p> <p>Solutions:</p> <p>Increase <code>MAX_DISTANCE_JUMP_M</code> threshold to ignore outliers:</p> <pre><code>const MAX_DISTANCE_JUMP_M = 2000; // Was 1000, increase to 2000\n</code></pre>"},{"location":"v2/features/map/tracking/#issue-route-polyline-jagged","title":"Issue: Route Polyline Jagged","text":"<p>Symptoms: Route looks zigzag instead of smooth</p> <p>Causes: GPS accuracy poor (\u00b120m)</p> <p>Solutions:</p> <p>Apply smoothing algorithm to polyline:</p> <pre><code>import { simplify } from '@turf/turf';\n\nconst smoothed = simplify(polyline, { tolerance: 0.0001, highQuality: true });\n</code></pre>"},{"location":"v2/features/map/tracking/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/features/map/tracking/#batch-point-insertion","title":"Batch Point Insertion","text":"<p>Efficient Bulk Insert:</p> <pre><code>// 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</code></pre>"},{"location":"v2/features/map/tracking/#query-optimization","title":"Query Optimization","text":"<p>Index for Route Queries:</p> <pre><code>CREATE INDEX idx_track_points_session_time ON \"TrackPoint\" (\"trackingSessionId\", \"recordedAt\");\n</code></pre> <p>Efficient Route Query:</p> <pre><code>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</code></pre>"},{"location":"v2/features/map/tracking/#related-documentation","title":"Related Documentation","text":"<ul> <li>Canvassing \u2014 Canvass session integration</li> <li>Tracking Backend Module</li> <li>MyRoutesPage</li> <li>TrackingSession Model</li> </ul>"},{"location":"v2/features/map/walk-sheets/","title":"Walk Sheets & QR Codes","text":""},{"location":"v2/features/map/walk-sheets/#overview","title":"Overview","text":"<p>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.</p> <p>Key Features:</p> <ul> <li>Browser-based printing (no server-side PDF generation)</li> <li>Customizable headers, footers, and QR codes</li> <li>Cut-based address filtering</li> <li>Point-in-polygon location selection</li> <li>Print-optimized layout (A4/Letter)</li> <li>Cut export reports with statistics</li> <li>Multi-unit building support</li> <li>Support level indicators</li> </ul> <p>Use Cases:</p> <ul> <li>Door-to-door canvassing</li> <li>Volunteer shift materials</li> <li>Cut logistics planning</li> <li>Campaign resource distribution</li> <li>Field data collection</li> </ul> <p>Architecture Highlights:</p> <ul> <li>Frontend-only printing (window.print())</li> <li>QR code generation via public API</li> <li>MapSettings singleton for configuration</li> <li>Point-in-polygon filtering for cut locations</li> <li>CSS @media print rules for layout</li> </ul>"},{"location":"v2/features/map/walk-sheets/#architecture","title":"Architecture","text":"<pre><code>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</code></pre> <p>Data Flow:</p> <ol> <li>Configuration Phase:</li> <li>Admin configures walk sheet settings (title, subtitle, footer, QR codes)</li> <li>Settings stored in MapSettings singleton</li> <li> <p>QR code URLs and labels defined (up to 3)</p> </li> <li> <p>Generation Phase:</p> </li> <li>Admin selects cut from dropdown</li> <li>Frontend fetches cut details and settings</li> <li>Point-in-polygon filter retrieves locations within cut</li> <li>QR codes generated via POST /api/qr/generate</li> <li> <p>Walk sheet rendered with all components</p> </li> <li> <p>Print Phase:</p> </li> <li>window.print() triggered</li> <li>Browser print dialog opens</li> <li>Print CSS rules applied (hide nav, adjust layout)</li> <li>User selects printer or \"Save as PDF\"</li> </ol>"},{"location":"v2/features/map/walk-sheets/#database-models","title":"Database Models","text":""},{"location":"v2/features/map/walk-sheets/#mapsettings-model","title":"MapSettings Model","text":"<pre><code>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</code></pre> <p>Singleton Pattern: - Always ID = 1 - Created during seed if not exists - Single source of truth for walk sheet config</p>"},{"location":"v2/features/map/walk-sheets/#cut-model","title":"Cut Model","text":"<pre><code>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</code></pre> <p>GeoJSON Structure: <pre><code>{\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</code></pre></p>"},{"location":"v2/features/map/walk-sheets/#location-model","title":"Location Model","text":"<pre><code>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</code></pre>"},{"location":"v2/features/map/walk-sheets/#address-model","title":"Address Model","text":"<pre><code>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</code></pre> <p>Support Level Scale: - 1 = Strong Opposition - 2 = Lean Opposition - 3 = Undecided - 4 = Lean Support - 5 = Strong Support</p>"},{"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":"<p>Fetch walk sheet configuration.</p> <p>Authentication: Required (SUPER_ADMIN, MAP_ADMIN)</p> <p>Response: <pre><code>{\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</code></pre></p>"},{"location":"v2/features/map/walk-sheets/#put-apimap-settings","title":"PUT /api/map-settings","text":"<p>Update walk sheet configuration.</p> <p>Authentication: Required (SUPER_ADMIN, MAP_ADMIN)</p> <p>Request Body: <pre><code>{\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</code></pre></p> <p>Response: Updated MapSettings object</p> <p>Validation: - walkSheetTitle: 1-100 characters - walkSheetSubtitle: 0-200 characters - walkSheetFooter: 0-500 characters - qrCode URLs: valid HTTP/HTTPS URLs - qrCode labels: 0-50 characters</p>"},{"location":"v2/features/map/walk-sheets/#get-apicutsid","title":"GET /api/cuts/:id","text":"<p>Fetch cut details for walk sheet.</p> <p>Authentication: Required</p> <p>Response: <pre><code>{\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</code></pre></p>"},{"location":"v2/features/map/walk-sheets/#get-apilocationscutidid","title":"GET /api/locations?cutId=:id","text":"<p>Fetch locations within cut boundary.</p> <p>Authentication: Required</p> <p>Query Parameters: - <code>cutId</code> (required): Cut ID for filtering - <code>sortBy</code> (optional): Field to sort by (default: \"address\") - <code>order</code> (optional): \"asc\" or \"desc\" (default: \"asc\")</p> <p>Response: <pre><code>{\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</code></pre></p> <p>Filtering Logic: <pre><code>// 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</code></pre></p>"},{"location":"v2/features/map/walk-sheets/#post-apiqrgenerate","title":"POST /api/qr/generate","text":"<p>Generate QR code PNG from URL.</p> <p>Authentication: None (public endpoint)</p> <p>Request Body: <pre><code>{\n \"url\": \"https://example.com/campaign\",\n \"size\": 200\n}\n</code></pre></p> <p>Parameters: - <code>url</code> (required): Target URL for QR code - <code>size</code> (optional): QR code dimension in pixels (default: 200, max: 500)</p> <p>Response: <pre><code>{\n \"png\": \"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA...\"\n}\n</code></pre></p> <p>Error Responses: - 400: Invalid URL format - 400: Size must be between 50-500 - 500: QR code generation failed</p> <p>Rate Limiting: 100 requests per 15 minutes per IP</p>"},{"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":"<p>Access via: Admin \u2192 Settings \u2192 Map Settings</p> Setting Type Default Max Length Description walkSheetTitle string \"Walk Sheet\" 100 Header title for walk sheets walkSheetSubtitle string \"\" 200 Subtitle below title (ward, date, etc.) walkSheetFooter string \"\" 500 Footer text (contact info, instructions) qrCode1Url string null 2048 First QR code target URL qrCode1Label string null 50 First QR code label qrCode2Url string null 2048 Second QR code target URL qrCode2Label string null 50 Second QR code label qrCode3Url string null 2048 Third QR code target URL qrCode3Label string null 50 Third QR code label <p>QR Code URL Examples: - Campaign page: <code>https://example.com/campaigns/123</code> - Volunteer portal: <code>https://example.com/volunteer</code> - Donation page: <code>https://example.com/donate</code> - Social media: <code>https://facebook.com/campaignpage</code> - Google Form: <code>https://forms.google.com/...</code></p> <p>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\")</p>"},{"location":"v2/features/map/walk-sheets/#print-configuration","title":"Print Configuration","text":"<p>CSS Variables: <pre><code>@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</code></pre></p> <p>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)</p>"},{"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":"<p>Step 1: Navigate to Map Settings</p> <ol> <li>Log in as SUPER_ADMIN or MAP_ADMIN</li> <li>Click Settings in sidebar</li> <li>Click Map Settings submenu</li> <li>Scroll to \"Walk Sheet Configuration\" section</li> </ol> <p>Step 2: Set Title and Subtitle</p> <pre><code>Walk Sheet Title: \"Toronto Canvass Walk Sheet\"\nWalk Sheet Subtitle: \"Ward 10 - November 2025 Campaign\"\n</code></pre> <p>Step 3: Configure QR Codes</p> <pre><code>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</code></pre> <p>Step 4: Set Footer Text</p> <pre><code>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</code></pre> <p>Step 5: Save Settings</p> <ul> <li>Click Save button</li> <li>Success notification appears</li> <li>Settings applied to all future walk sheets</li> </ul>"},{"location":"v2/features/map/walk-sheets/#generate-walk-sheet","title":"Generate Walk Sheet","text":"<p>Step 1: Navigate to Walk Sheet Page</p> <ol> <li>Click Map in sidebar</li> <li>Click Walk Sheet submenu</li> <li>Walk sheet generator page loads</li> </ol> <p>Step 2: Select Cut</p> <ol> <li>Click Select Cut dropdown</li> <li>Choose cut from list (e.g., \"Downtown Core\")</li> <li>Loading indicator shows while fetching locations</li> <li>Location count displayed (e.g., \"150 locations\")</li> </ol> <p>Step 3: Preview Walk Sheet</p> <p>Walk sheet displays:</p> <pre><code>\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</code></pre> <p>Step 4: Print Walk Sheet</p> <ol> <li>Click Print button (top-right corner)</li> <li>Browser print dialog opens</li> <li>Configure print settings:</li> <li>Destination: Printer or \"Save as PDF\"</li> <li>Pages: All</li> <li>Layout: Portrait</li> <li>Margins: Default</li> <li>Background graphics: Enabled</li> <li>Click Print or Save</li> </ol> <p>Step 5: Distribute to Volunteers</p> <ul> <li>Print multiple copies for shift volunteers</li> <li>Include shift assignment sheet</li> <li>Provide pens for checkboxes and notes</li> <li>Brief volunteers on walk sheet usage</li> </ul>"},{"location":"v2/features/map/walk-sheets/#generate-cut-export-report","title":"Generate Cut Export Report","text":"<p>Step 1: Navigate to Cuts Page</p> <ol> <li>Click Map \u2192 Cuts in sidebar</li> <li>Cuts table loads with list of all cuts</li> </ol> <p>Step 2: Open Cut Export</p> <ol> <li>Find cut row (e.g., \"Downtown Core\")</li> <li>Click Export button in Actions column</li> <li>New tab opens with export report</li> </ol> <p>Step 3: Review Statistics</p> <p>Export report shows:</p> <pre><code>\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</code></pre> <p>Step 4: Export to CSV</p> <ol> <li>Click Export CSV button</li> <li>File downloads: <code>cut-42-downtown-core-2026-02-13.csv</code></li> <li>Open in spreadsheet for further analysis</li> </ol> <p>CSV Format: <pre><code>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</code></pre></p>"},{"location":"v2/features/map/walk-sheets/#print-layout","title":"Print Layout","text":""},{"location":"v2/features/map/walk-sheets/#page-structure","title":"Page Structure","text":"<pre><code>\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</code></pre>"},{"location":"v2/features/map/walk-sheets/#css-print-rules","title":"CSS Print Rules","text":"<p>Component: WalkSheetPage.tsx</p> <pre><code>@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</code></pre>"},{"location":"v2/features/map/walk-sheets/#address-table-layout","title":"Address Table Layout","text":"<p>Column Structure:</p> Column Width Content Sort Address 40% Street address Alphabetical Unit 10% Unit/apartment number Alphanumeric Name 20% First + Last name Alphabetical Support 10% Support level (1-5) Color-coded Notes 15% Canvasser notes N/A Visited 5% Checkbox N/A <p>Multi-Unit Grouping:</p> <pre><code>\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</code></pre> <p>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)</p>"},{"location":"v2/features/map/walk-sheets/#qr-code-layout","title":"QR Code Layout","text":"<p>Horizontal Layout:</p> <pre><code> [QR 150\u00d7150] [QR 150\u00d7150] [QR 150\u00d7150]\n Campaign Page Volunteer Info Donate Now\n</code></pre> <p>QR Code Generation: - Size: 150\u00d7150 pixels - Error correction: Medium (M) - Format: PNG with transparent background - Encoding: UTF-8 - Margin: 4 modules</p> <p>Spacing: - Between codes: 30px - Above section: 20px - Below section: 20px - Label margin: 5px</p>"},{"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":"<p>Component: CutExportPage.tsx</p> <p>Route: <code>/app/map/cuts/:id/export</code></p> <p>Layout:</p> <pre><code>\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</code></pre>"},{"location":"v2/features/map/walk-sheets/#statistics-panel","title":"Statistics Panel","text":"<p>Metrics Displayed:</p> <ol> <li>Total Locations: Count of locations within cut</li> <li>Total Units: Sum of addresses across all locations</li> <li>Geocoded Locations: Locations with lat/lng (% of total)</li> <li>Missing Coordinates: Locations without lat/lng</li> <li>Residential Units: Units with buildingUse = 1</li> <li>Commercial Units: Units with buildingUse != 1</li> <li>Support Level Breakdown: Count by level (1-5)</li> <li>Cut Area: Approximate area in square kilometers</li> </ol> <p>Statistics Calculation:</p> <pre><code>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</code></pre>"},{"location":"v2/features/map/walk-sheets/#location-table","title":"Location Table","text":"<p>Columns:</p> Column Data Format Address location.address String Latitude location.latitude 6 decimals Longitude location.longitude 6 decimals Postal Code location.postalCode Uppercase Units addresses.length Integer Residential buildingUse === 1 Boolean Support Avg avg(addresses.supportLevel) 1 decimal <p>Table Features:</p> <ul> <li>Sortable by all columns</li> <li>Filterable by postal code prefix</li> <li>Pagination (50 per page)</li> <li>Export selected rows to CSV</li> <li>Highlight locations with missing coordinates</li> <li>Color-code by average support level</li> </ul>"},{"location":"v2/features/map/walk-sheets/#csv-export","title":"CSV Export","text":"<p>Export Button Handler:</p> <pre><code>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</code></pre> <p>CSV Output Example:</p> <pre><code>\"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</code></pre>"},{"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":"<pre><code>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</code></pre>"},{"location":"v2/features/map/walk-sheets/#qr-code-api-qrroutests","title":"QR Code API - qr.routes.ts","text":"<pre><code>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</code></pre>"},{"location":"v2/features/map/walk-sheets/#mapsettingspagetsx-qr-code-configuration","title":"MapSettingsPage.tsx - QR Code Configuration","text":"<pre><code>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</code></pre>"},{"location":"v2/features/map/walk-sheets/#cutexportpagetsx-statistics-and-csv-export","title":"CutExportPage.tsx - Statistics and CSV Export","text":"<pre><code>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</code></pre>"},{"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":"<p>Symptoms: - Empty QR code section on walk sheet - Console errors about <code>/api/qr/generate</code> - Network 404 or 500 errors</p> <p>Solutions:</p> <ol> <li> <p>Verify endpoint accessibility: <pre><code>curl -X POST http://localhost:4000/api/qr/generate \\\n -H \"Content-Type: application/json\" \\\n -d '{\"url\":\"https://example.com\",\"size\":200}'\n</code></pre></p> </li> <li> <p>Check qrcode package installed: <pre><code>cd api\nnpm list qrcode\n# If not installed:\nnpm install qrcode\nnpm install --save-dev @types/qrcode\n</code></pre></p> </li> <li> <p>Verify route registration in server.ts: <pre><code>import qrRoutes from './modules/qr/qr.routes';\napp.use('/api/qr', qrRoutes);\n</code></pre></p> </li> <li> <p>Check URL validation: <pre><code>// 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</code></pre></p> </li> <li> <p>Test with simple URL: <pre><code>// 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</code></pre></p> </li> </ol>"},{"location":"v2/features/map/walk-sheets/#problem-print-layout-broken","title":"Problem: Print layout broken","text":"<p>Symptoms: - Elements overlap when printing - Missing borders or backgrounds - Incorrect page breaks - Cut-off content</p> <p>Solutions:</p> <ol> <li>Enable background graphics in browser:</li> <li>Chrome: Print \u2192 More settings \u2192 Background graphics (checked)</li> <li>Firefox: Print \u2192 Options \u2192 Print backgrounds (checked)</li> <li> <p>Safari: Print \u2192 Show Details \u2192 Print backgrounds (checked)</p> </li> <li> <p>Test print preview first: <pre><code>// 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</code></pre></p> </li> <li> <p>Check @page margins: <pre><code>@media print {\n @page {\n size: A4 portrait;\n margin: 0.5in; /* Adjust if content cut off */\n }\n}\n</code></pre></p> </li> <li> <p>Prevent table row breaks: <pre><code>@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</code></pre></p> </li> <li> <p>Test in different browsers:</p> </li> <li>Chrome/Edge: Best print CSS support</li> <li>Firefox: Good, but some layout differences</li> <li> <p>Safari: May require webkit prefixes</p> </li> <li> <p>Adjust font sizes if content overflows: <pre><code>@media print {\n body { font-size: 9pt; } /* Reduce from 10pt */\n th, td { font-size: 8pt; } /* Reduce from 9pt */\n}\n</code></pre></p> </li> </ol>"},{"location":"v2/features/map/walk-sheets/#problem-walk-sheet-showing-wrong-cut","title":"Problem: Walk sheet showing wrong cut","text":"<p>Symptoms: - Selected cut shows different locations - Location count doesn't match cut - Locations outside cut boundary visible</p> <p>Solutions:</p> <ol> <li> <p>Verify cutId in API request: <pre><code>console.log('Fetching locations for cut:', selectedCutId);\nconst { data } = await api.get(`/locations?cutId=${selectedCutId}`);\nconsole.log('Received locations:', data.data.length);\n</code></pre></p> </li> <li> <p>Check point-in-polygon filter: <pre><code>// 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</code></pre></p> </li> <li> <p>Test with simple rectangular cut: <pre><code>{\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</code></pre></p> </li> <li> <p>Verify GeoJSON coordinate order: <pre><code>// Correct: [longitude, latitude]\nconst point = [loc.longitude, loc.latitude]; // \u2713\n\n// Incorrect: [latitude, longitude]\nconst point = [loc.latitude, loc.longitude]; // \u2717\n</code></pre></p> </li> <li> <p>Check cut geojson validity:</p> </li> <li>First and last coordinates must be identical (closed polygon)</li> <li>Coordinates must be <code>[lng, lat]</code> order</li> <li>Use http://geojson.io to visualize</li> </ol>"},{"location":"v2/features/map/walk-sheets/#problem-large-cuts-slow-to-load","title":"Problem: Large cuts slow to load","text":"<p>Symptoms: - Walk sheet takes > 10 seconds to load - Browser freezes during render - Print preview crashes</p> <p>Solutions:</p> <ol> <li> <p>Implement pagination: <pre><code>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</code></pre></p> </li> <li> <p>Add location count warning: <pre><code>{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</code></pre></p> </li> <li> <p>Use virtual scrolling for preview: <pre><code>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</code></pre></p> </li> <li> <p>Optimize QR code generation: <pre><code>// 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</code></pre></p> </li> <li> <p>Split large cuts into multiple sheets: <pre><code>// 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</code></pre></p> </li> </ol>"},{"location":"v2/features/map/walk-sheets/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/features/map/walk-sheets/#client-side-rendering","title":"Client-Side Rendering","text":"<p>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)</p> <p>Optimization Strategies:</p> <ol> <li> <p>Lazy load QR codes: <pre><code>// 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</code></pre></p> </li> <li> <p>Cache QR codes in localStorage: <pre><code>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</code></pre></p> </li> <li> <p>Debounce cut selection: <pre><code>import { debounce } from 'lodash';\n\nconst debouncedFetchLocations = debounce((cutId: number) => {\n fetchLocations(cutId);\n}, 300);\n\n<Select onChange={debouncedFetchLocations} />\n</code></pre></p> </li> </ol>"},{"location":"v2/features/map/walk-sheets/#server-side-performance","title":"Server-Side Performance","text":"<p>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)</p> <p>Database Optimization:</p> <pre><code>-- 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</code></pre> <p>Query Optimization:</p> <pre><code>// 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</code></pre>"},{"location":"v2/features/map/walk-sheets/#print-performance","title":"Print Performance","text":"<p>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</p> <p>Browser Print Limits: - Chrome: ~1000 table rows before slowdown - Firefox: ~800 table rows - Safari: ~600 table rows</p> <p>Optimization: - Use <code>page-break-inside: avoid</code> sparingly - Minimize complex CSS in print rules - Avoid large images (QR codes already optimized at 150px) - Split very large cuts into multiple PDFs</p>"},{"location":"v2/features/map/walk-sheets/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/features/map/walk-sheets/#backend-documentation","title":"Backend Documentation","text":"<ul> <li>QR Code Generation: <code>api/src/modules/qr/qr.routes.ts</code></li> <li>QRCode.toDataURL() wrapper</li> <li>Rate limiting (100/15min)</li> <li> <p>Size validation (50-500px)</p> </li> <li> <p>Map Settings: <code>api/src/modules/map/settings/</code></p> </li> <li>MapSettings singleton CRUD</li> <li>Walk sheet configuration</li> <li> <p>QR code URL storage</p> </li> <li> <p>Cuts API: <code>api/src/modules/map/cuts/</code></p> </li> <li>Cut CRUD operations</li> <li>GeoJSON polygon storage</li> <li> <p>Point-in-polygon filtering</p> </li> <li> <p>Locations API: <code>api/src/modules/map/locations/</code></p> </li> <li>Location CRUD with cut filtering</li> <li>Address relations</li> <li>Support level tracking</li> </ul>"},{"location":"v2/features/map/walk-sheets/#frontend-documentation","title":"Frontend Documentation","text":"<ul> <li>Walk Sheet Page: <code>admin/src/pages/WalkSheetPage.tsx</code></li> <li>Cut selection dropdown</li> <li>Location table rendering</li> <li>QR code display</li> <li> <p>Print functionality</p> </li> <li> <p>Cut Export Page: <code>admin/src/pages/CutExportPage.tsx</code></p> </li> <li>Statistics calculation</li> <li>CSV export</li> <li> <p>Print layout</p> </li> <li> <p>Map Settings Page: <code>admin/src/pages/MapSettingsPage.tsx</code></p> </li> <li>Walk sheet configuration form</li> <li>QR code URL/label inputs</li> <li>Settings persistence</li> </ul>"},{"location":"v2/features/map/walk-sheets/#database-documentation","title":"Database Documentation","text":"<ul> <li>Models: <code>api/prisma/schema.prisma</code></li> <li>MapSettings (singleton)</li> <li>Cut (geojson polygon)</li> <li>Location (geocoded addresses)</li> <li>Address (unit-level data)</li> </ul>"},{"location":"v2/features/map/walk-sheets/#external-resources","title":"External Resources","text":"<ul> <li>QRCode.js: https://github.com/soldair/node-qrcode</li> <li>PNG generation API</li> <li>Error correction levels</li> <li> <p>Size/margin options</p> </li> <li> <p>CSS Print Media: https://developer.mozilla.org/en-US/docs/Web/CSS/@media/print</p> </li> <li>@media print rules</li> <li>@page configuration</li> <li> <p>page-break properties</p> </li> <li> <p>GeoJSON Specification: https://geojson.org/</p> </li> <li>Polygon format</li> <li>Coordinate order ([lng, lat])</li> <li>MultiPolygon support</li> </ul>"},{"location":"v2/features/media/","title":"Media Manager","text":"<p>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.</p>"},{"location":"v2/features/media/#overview","title":"Overview","text":"<p>The Media Manager consists of four integrated components:</p> <ol> <li>Video Library - Admin video management</li> <li>Upload System - Video upload with metadata extraction</li> <li>Public Gallery - Public video sharing</li> <li>Job Queue - Background job monitoring</li> </ol>"},{"location":"v2/features/media/#features","title":"Features","text":""},{"location":"v2/features/media/#video-library","title":"Video Library","text":"<ul> <li>Video CRUD operations</li> <li>Metadata editing (title, description, tags)</li> <li>Lock/unlock videos</li> <li>Bulk operations (delete, lock, share)</li> <li>Search and filtering</li> <li>Thumbnail generation (future)</li> </ul>"},{"location":"v2/features/media/#upload-system","title":"Upload System","text":"<ul> <li>Drag-and-drop upload</li> <li>Single and batch upload</li> <li>Progress tracking</li> <li>Automatic metadata extraction (FFprobe)</li> <li>Duration</li> <li>Dimensions (width x height)</li> <li>Orientation (landscape/portrait/square)</li> <li>Quality (resolution-based: 4K, 1080p, 720p, etc.)</li> <li>Audio detection</li> <li>File validation (type, size)</li> <li>Supported formats: MP4, MOV, AVI, MKV, WebM, M4V, FLV</li> <li>Max file size: 10GB</li> </ul>"},{"location":"v2/features/media/#public-gallery","title":"Public Gallery","text":"<ul> <li>Shared videos display</li> <li>Category filtering</li> <li>Reaction system (6 emojis)</li> <li>Video cards with thumbnails</li> <li>Lock/unlock control</li> <li>Visitor reactions</li> </ul>"},{"location":"v2/features/media/#reaction-system","title":"Reaction System","text":"<p>Six emoji reactions:</p> <ul> <li>Like (\ud83d\udc4d)</li> <li>Love (\u2764\ufe0f)</li> <li>Laugh (\ud83d\ude02)</li> <li>Wow (\ud83d\ude2e)</li> <li>Sad (\ud83d\ude22)</li> <li>Angry (\ud83d\ude21)</li> </ul>"},{"location":"v2/features/media/#architecture","title":"Architecture","text":""},{"location":"v2/features/media/#dual-api-design","title":"Dual API Design","text":"<p>Express API (Port 4000) - Main V2 features - Prisma ORM - PostgreSQL</p> <p>Fastify Media API (Port 4100) - Media-specific operations - Drizzle ORM - Same PostgreSQL database - Optimized for file uploads</p>"},{"location":"v2/features/media/#backend-components","title":"Backend Components","text":"<p>Media API: - <code>api/src/media-server.ts</code> - Fastify entry point - <code>api/src/modules/media/routes/</code> - Video, upload, shared, reactions, jobs - <code>api/src/modules/media/services/</code> - FFprobe, video service - <code>api/src/modules/media/db/schema.ts</code> - Drizzle schema</p> <p>Database Tables: - <code>videos</code> - Video metadata (Drizzle) - <code>shared_media</code> - Public gallery (Drizzle) - <code>media_reactions</code> - Reaction tracking (Drizzle) - <code>media_jobs</code> - Job queue (Drizzle)</p>"},{"location":"v2/features/media/#frontend-components","title":"Frontend Components","text":"<p>Admin Pages: - <code>admin/src/pages/media/LibraryPage.tsx</code> - Video library management - <code>admin/src/pages/media/SharedMediaPage.tsx</code> - Public gallery admin - <code>admin/src/pages/media/MediaJobsPage.tsx</code> - Job queue monitoring</p> <p>Public Pages: - <code>admin/src/pages/public/MediaGalleryPage.tsx</code> - Public video gallery - <code>admin/src/pages/public/MediaViewerPage.tsx</code> - Video detail page</p> <p>Components: - <code>admin/src/components/media/VideoCard.tsx</code> - Video display card - <code>admin/src/components/media/BulkActions.tsx</code> - Batch operations - <code>admin/src/components/media/UploadVideoModal.tsx</code> - Upload interface</p> <p>API Clients: - <code>admin/src/lib/media-api.ts</code> - Authenticated media API client - <code>admin/src/lib/media-public-api.ts</code> - Public media API client</p>"},{"location":"v2/features/media/#configuration","title":"Configuration","text":""},{"location":"v2/features/media/#environment-variables","title":"Environment Variables","text":"<pre><code># 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</code></pre>"},{"location":"v2/features/media/#docker-volumes","title":"Docker Volumes","text":"<pre><code>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</code></pre>"},{"location":"v2/features/media/#upload-system_1","title":"Upload System","text":""},{"location":"v2/features/media/#upload-flow","title":"Upload Flow","text":"<ol> <li>Select Files</li> <li>Drag-and-drop or file picker</li> <li>Multiple file selection</li> <li> <p>File type validation</p> </li> <li> <p>Upload to Inbox</p> </li> <li>Stream to <code>/media/local/inbox</code></li> <li>UUID filename (prevents conflicts)</li> <li> <p>Progress tracking</p> </li> <li> <p>Extract Metadata</p> </li> <li>FFprobe analysis (30s timeout)</li> <li>Duration, dimensions, orientation</li> <li>Quality calculation</li> <li> <p>Audio detection</p> </li> <li> <p>Create Database Record</p> </li> <li>Store metadata</li> <li>Set initial status</li> <li> <p>Link to user</p> </li> <li> <p>Process Video (Future)</p> </li> <li>Generate thumbnail</li> <li>Transcode formats</li> <li>Move to library</li> </ol>"},{"location":"v2/features/media/#ffprobe-metadata-extraction","title":"FFprobe Metadata Extraction","text":"<p>Automatically extracts:</p> <pre><code>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</code></pre> <p>Quality Calculation: - 4K: \u22652160p (3840x2160) - 1080p: \u22651080p (1920x1080) - 720p: \u2265720p (1280x720) - SD: <720p</p> <p>Orientation: - Landscape: width > height - Portrait: height > width - Square: width \u2248 height (within 10%)</p>"},{"location":"v2/features/media/#supported-formats","title":"Supported Formats","text":"<ul> <li>MP4 - H.264/H.265 video</li> <li>MOV - QuickTime</li> <li>AVI - Audio Video Interleave</li> <li>MKV - Matroska</li> <li>WebM - Web-optimized</li> <li>M4V - iTunes video</li> <li>FLV - Flash video</li> </ul>"},{"location":"v2/features/media/#public-gallery_1","title":"Public Gallery","text":""},{"location":"v2/features/media/#sharing-system","title":"Sharing System","text":"<p>Videos can be shared publicly:</p> <ol> <li>Lock/Unlock - Control public visibility</li> <li>Category Assignment - Organize by category</li> <li>Public Access - View at <code>/media</code></li> <li>Reactions - Emoji reactions from visitors</li> </ol>"},{"location":"v2/features/media/#categories","title":"Categories","text":"<p>Predefined categories:</p> <ul> <li>Events</li> <li>Testimonials</li> <li>Tutorials</li> <li>Announcements</li> <li>Behind the Scenes</li> <li>Custom categories</li> </ul>"},{"location":"v2/features/media/#reaction-system_1","title":"Reaction System","text":""},{"location":"v2/features/media/#reaction-tracking","title":"Reaction Tracking","text":"<pre><code>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</code></pre>"},{"location":"v2/features/media/#session-based-reactions","title":"Session-Based Reactions","text":"<ul> <li>One reaction per video per session</li> <li>Session ID in localStorage</li> <li>Update reaction (change type)</li> <li>Remove reaction (click again)</li> </ul>"},{"location":"v2/features/media/#job-queue","title":"Job Queue","text":"<p>Background jobs for:</p> <ul> <li>Video processing</li> <li>Thumbnail generation</li> <li>Format transcoding</li> <li>Metadata extraction</li> <li>File cleanup</li> </ul>"},{"location":"v2/features/media/#job-monitoring","title":"Job Monitoring","text":"<p>Admin can:</p> <ul> <li>View job status</li> <li>Monitor progress</li> <li>Retry failed jobs</li> <li>View error logs</li> </ul>"},{"location":"v2/features/media/#database-schema-drizzle","title":"Database Schema (Drizzle)","text":""},{"location":"v2/features/media/#videos-table","title":"Videos Table","text":"<pre><code>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</code></pre>"},{"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":"<pre><code>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</code></pre>"},{"location":"v2/features/media/#public-endpoints","title":"Public Endpoints","text":"<pre><code>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</code></pre>"},{"location":"v2/features/media/#security","title":"Security","text":""},{"location":"v2/features/media/#file-validation","title":"File Validation","text":"<ul> <li>MIME type checking</li> <li>File extension validation</li> <li>File size limits (10GB max)</li> <li>Path traversal prevention</li> <li>Null byte detection</li> </ul>"},{"location":"v2/features/media/#access-control","title":"Access Control","text":"<ul> <li>Admin-only uploads</li> <li>Lock/unlock controls</li> <li>Public visibility flags</li> <li>Session-based reactions</li> </ul>"},{"location":"v2/features/media/#performance","title":"Performance","text":""},{"location":"v2/features/media/#upload-optimization","title":"Upload Optimization","text":"<ul> <li>Streaming uploads (no memory buffering)</li> <li>UUID filenames (no collisions)</li> <li>Async metadata extraction</li> <li>Progress tracking</li> </ul>"},{"location":"v2/features/media/#gallery-optimization","title":"Gallery Optimization","text":"<ul> <li>Lazy loading</li> <li>Thumbnail caching (future)</li> <li>Paginated results</li> <li>Infinite scroll</li> </ul>"},{"location":"v2/features/media/#related-documentation","title":"Related Documentation","text":"<ul> <li>Video Library</li> <li>Upload System</li> <li>Public Gallery</li> <li>Job Queue</li> <li>Backend Media Module</li> <li>Library Page</li> <li>Upload Modal Component</li> <li>FFprobe Service</li> </ul>"},{"location":"v2/features/media/jobs/","title":"Media Job Queue System","text":""},{"location":"v2/features/media/jobs/#overview","title":"Overview","text":"<p>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.</p> <p>Key Features:</p> <ul> <li>Resource Categories \u2014 Jobs classified by resource needs (CPU, GPU encode, GPU AI)</li> <li>Priority Scheduling \u2014 High-priority jobs processed first within same category</li> <li>Job Types \u2014 15+ job types (compilation, encoding, digest generation, scene extraction, etc.)</li> <li>Progress Tracking \u2014 Real-time progress updates (0-100%)</li> <li>Status Management \u2014 Pending \u2192 Queued \u2192 Running \u2192 Completed/Failed lifecycle</li> <li>Retry Logic \u2014 Failed jobs can be retried with exponential backoff</li> <li>Detailed Logging \u2014 Execution logs for debugging and audit trail</li> <li>Queue Management \u2014 Pause, resume, cancel, and prioritize jobs</li> <li>VRAM Awareness \u2014 Prevents GPU memory exhaustion by tracking VRAM requirements</li> </ul> <p>Access Control:</p> <ul> <li>Job viewing/management requires <code>SUPER_ADMIN</code> role</li> <li>Job creation can be triggered by admins or automated workflows</li> </ul> <p>Technology Stack:</p> <ul> <li>Database Queue \u2014 PostgreSQL-backed job queue (no BullMQ for media)</li> <li>Worker Process \u2014 Node.js worker polling queue every 5 seconds</li> <li>FFmpeg \u2014 Video encoding and compilation</li> <li>AI Integration \u2014 Future support for scene detection and auto-tagging</li> </ul>"},{"location":"v2/features/media/jobs/#architecture","title":"Architecture","text":"<pre><code>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</code></pre> <p>Workflow:</p> <ol> <li>Job Creation \u2014 Admin clicks \"Re-encode\" button, API creates job record</li> <li>Queue Polling \u2014 Worker checks for pending jobs every 5 seconds</li> <li>Resource Check \u2014 Worker verifies sufficient VRAM/CPU available</li> <li>Job Execution \u2014 Worker runs appropriate processor (FFmpeg, AI script, etc.)</li> <li>Progress Updates \u2014 Worker updates job progress every ~5% completion</li> <li>Completion \u2014 Worker marks job complete and logs results</li> <li>Retry on Failure \u2014 Failed jobs can be retried with exponential backoff</li> </ol>"},{"location":"v2/features/media/jobs/#database-model","title":"Database Model","text":""},{"location":"v2/features/media/jobs/#jobs-table-schema","title":"Jobs Table Schema","text":"<pre><code>// 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</code></pre>"},{"location":"v2/features/media/jobs/#job-types-enum","title":"Job Types Enum","text":"Type Resource Category VRAM (MB) Description <code>scan</code> cpu 0 Scan directory for new videos <code>public_scan</code> cpu 0 Scan public gallery directory <code>validate</code> cpu 0 Validate video metadata (FFprobe) <code>reencode_streaming</code> gpu_encode 4000 Re-encode for web playback (H.264) <code>compile_random</code> gpu_encode 2000 Random video compilation <code>compile_quad</code> gpu_encode 4000 4-up grid compilation <code>compile_mega</code> gpu_encode 6000 Large multi-video compilation <code>compile_gif</code> cpu 0 Create GIF from video <code>digest_generate</code> gpu_ai 8000 AI-powered video digest <code>clip_generate</code> gpu_ai 6000 Extract clips from digest <code>highlight_generate</code> gpu_ai 8000 Create highlight reel <code>tag_generation</code> gpu_ai 6000 AI auto-tagging <code>scene_extract</code> gpu_ai 8000 Scene detection and extraction <code>thumbnail_generate</code> cpu 0 Generate thumbnail from video <code>move_to_library</code> 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 <code>pending</code> Waiting to be picked up by worker No <code>queued</code> Selected by worker, waiting for resources No <code>running</code> Currently executing No <code>completed</code> Finished successfully Yes <code>failed</code> Execution failed (see log for details) Yes <code>cancelled</code> Manually cancelled by admin Yes <code>paused</code> Temporarily paused (can be resumed) No"},{"location":"v2/features/media/jobs/#resource-categories","title":"Resource Categories","text":"Category Typical VRAM Concurrent Limit Use Cases <code>cpu</code> 0 MB 5 Scanning, validation, simple encodes, GIF creation <code>gpu_encode</code> 2-6 GB 2 Video re-encoding, compilation, format conversion <code>gpu_ai</code> 6-12 GB 1 AI tagging, scene detection, digest generation, highlight extraction <p>VRAM Management:</p> <p>Worker tracks total VRAM usage across running jobs:</p> <pre><code>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</code></pre>"},{"location":"v2/features/media/jobs/#api-endpoints","title":"API Endpoints","text":"<p>All endpoints require <code>SUPER_ADMIN</code> role.</p>"},{"location":"v2/features/media/jobs/#list-jobs","title":"List Jobs","text":"<pre><code>GET /api/media/jobs\n</code></pre> <p>Query Parameters:</p> Parameter Type Default Description <code>page</code> number 1 Page number <code>limit</code> number 20 Results per page <code>status</code> string - Filter by status (pending, running, completed, failed) <code>type</code> string - Filter by job type <code>resourceCategory</code> string - Filter by resource category <p>Response:</p> <pre><code>{\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</code></pre>"},{"location":"v2/features/media/jobs/#get-job-details","title":"Get Job Details","text":"<pre><code>GET /api/media/jobs/:id\n</code></pre> <p>Response:</p> <pre><code>{\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</code></pre>"},{"location":"v2/features/media/jobs/#create-job","title":"Create Job","text":"<pre><code>POST /api/media/jobs\n</code></pre> <p>Request Body:</p> <pre><code>{\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</code></pre> <p>Response:</p> <pre><code>{\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</code></pre>"},{"location":"v2/features/media/jobs/#retry-failed-job","title":"Retry Failed Job","text":"<pre><code>POST /api/media/jobs/:id/retry\n</code></pre> <p>Response:</p> <pre><code>{\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</code></pre> <p>Retry Logic:</p> <ul> <li>Failed jobs can be retried up to <code>maxRetries</code> times (default: 3)</li> <li>Exponential backoff: wait <code>2^retryCount</code> minutes before retry</li> <li>Retry resets status to <code>pending</code> and appends retry marker to log</li> </ul>"},{"location":"v2/features/media/jobs/#cancel-job","title":"Cancel Job","text":"<pre><code>POST /api/media/jobs/:id/cancel\n</code></pre> <p>Response:</p> <pre><code>{\n \"id\": \"550e8400-e29b-41d4-a716-446655440000\",\n \"status\": \"cancelled\",\n \"log\": \"Starting re-encode...\\nProgress: 25%\\n--- JOB CANCELLED BY ADMIN ---\"\n}\n</code></pre> <p>Notes:</p> <ul> <li>Running jobs cannot be cancelled immediately (worker must finish current chunk)</li> <li>Pending/queued jobs cancelled instantly</li> </ul>"},{"location":"v2/features/media/jobs/#pauseresume-job","title":"Pause/Resume Job","text":"<pre><code>POST /api/media/jobs/:id/pause\nPOST /api/media/jobs/:id/resume\n</code></pre> <p>Pause Response:</p> <pre><code>{\n \"id\": \"550e8400-e29b-41d4-a716-446655440000\",\n \"status\": \"paused\"\n}\n</code></pre> <p>Resume Response:</p> <pre><code>{\n \"id\": \"550e8400-e29b-41d4-a716-446655440000\",\n \"status\": \"pending\"\n}\n</code></pre>"},{"location":"v2/features/media/jobs/#queue-statistics","title":"Queue Statistics","text":"<pre><code>GET /api/media/jobs/stats\n</code></pre> <p>Response:</p> <pre><code>{\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</code></pre>"},{"location":"v2/features/media/jobs/#admin-workflow","title":"Admin Workflow","text":""},{"location":"v2/features/media/jobs/#viewing-job-queue","title":"Viewing Job Queue","text":"<ol> <li>Navigate to Media \u2192 Jobs in admin sidebar</li> <li>Table displays all jobs with:</li> <li>Job type icon</li> <li>Status badge (color-coded)</li> <li>Progress bar</li> <li>Priority indicator</li> <li>Resource category</li> <li>Created/started/completed times</li> <li>Use filters at top:</li> <li>Status dropdown (All / Pending / Running / Completed / Failed)</li> <li>Type dropdown (job type)</li> <li>Resource dropdown (CPU / GPU Encode / GPU AI)</li> </ol>"},{"location":"v2/features/media/jobs/#creating-jobs-manually","title":"Creating Jobs Manually","text":"<p>Option 1: From Library Page</p> <ol> <li>Select video in library table</li> <li>Click \"Actions\" dropdown</li> <li>Select action:</li> <li>\"Re-encode for Streaming\"</li> <li>\"Generate Thumbnail\"</li> <li>\"Validate Metadata\"</li> <li>\"Move to Directory\"</li> <li>Confirm job creation</li> <li>Redirected to Jobs page showing new job</li> </ol> <p>Option 2: From Jobs Page</p> <ol> <li>Click \"Create Job\" button</li> <li>Modal opens with form:</li> <li>Type dropdown (15+ job types)</li> <li>Video selector (search by title/filename)</li> <li>Priority slider (1-10)</li> <li>Parameters JSON editor (advanced)</li> <li>Click \"Create\"</li> <li>Job appears in pending queue</li> </ol>"},{"location":"v2/features/media/jobs/#monitoring-job-progress","title":"Monitoring Job Progress","text":"<p>Real-Time Updates:</p> <ol> <li>Jobs page polls API every 2 seconds for running jobs</li> <li>Progress bars update smoothly (0-100%)</li> <li>Status badges change color:</li> <li>Grey: Pending</li> <li>Blue: Queued</li> <li>Yellow: Running</li> <li>Green: Completed</li> <li>Red: Failed</li> </ol> <p>Detailed Logs:</p> <ol> <li>Click job row to expand details panel</li> <li>View execution log in monospace text area</li> <li>Log updates in real-time while job running</li> <li>Example log output:</li> </ol> <pre><code>[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</code></pre>"},{"location":"v2/features/media/jobs/#retrying-failed-jobs","title":"Retrying Failed Jobs","text":"<ol> <li>Filter for Failed jobs</li> <li>Click job row to view error log</li> <li>Identify failure reason (e.g., \"FFmpeg error: codec not supported\")</li> <li>Fix underlying issue (install codec, fix file path, etc.)</li> <li>Click \"Retry\" button</li> <li>Job resets to pending status</li> <li>Worker picks up job again</li> </ol> <p>Auto-Retry:</p> <p>Jobs automatically retry up to 3 times with exponential backoff:</p> <ul> <li>1<sup>st</sup> retry: after 2 minutes</li> <li>2<sup>nd</sup> retry: after 4 minutes</li> <li>3<sup>rd</sup> retry: after 8 minutes</li> </ul>"},{"location":"v2/features/media/jobs/#cancelling-jobs","title":"Cancelling Jobs","text":"<ol> <li>Find job in pending/queued/running state</li> <li>Click \"Cancel\" button</li> <li>Confirm cancellation dialog</li> <li>Job marked as cancelled</li> <li>If running, worker stops after current chunk completes</li> </ol>"},{"location":"v2/features/media/jobs/#pausingresuming-jobs","title":"Pausing/Resuming Jobs","text":"<p>Use Case: Temporarily stop low-priority jobs to free resources for urgent tasks</p> <ol> <li>Select low-priority pending job</li> <li>Click \"Pause\" button</li> <li>Job status changes to paused (greyed out)</li> <li>Worker skips paused jobs</li> <li>When ready, click \"Resume\"</li> <li>Job returns to pending queue</li> </ol>"},{"location":"v2/features/media/jobs/#job-type-details","title":"Job Type Details","text":""},{"location":"v2/features/media/jobs/#scan-jobs-scan-public_scan","title":"Scan Jobs (<code>scan</code>, <code>public_scan</code>)","text":"<p>Purpose: Scan filesystem directory for new videos and create database records</p> <p>Parameters:</p> <pre><code>{\n \"directoryType\": \"videos\",\n \"skipExisting\": true\n}\n</code></pre> <p>Process:</p> <ol> <li>Read directory <code>/media/local/library/{directoryType}/</code></li> <li>Filter for video extensions (<code>.mp4</code>, <code>.mov</code>, etc.)</li> <li>Check each file against database (by path)</li> <li>Create records for new files</li> <li>Run FFprobe on new files</li> <li>Update progress: files processed / total files</li> </ol> <p>Typical Duration: 2-30 seconds (depends on file count)</p>"},{"location":"v2/features/media/jobs/#validation-jobs-validate","title":"Validation Jobs (<code>validate</code>)","text":"<p>Purpose: Re-run FFprobe to refresh video metadata</p> <p>Parameters:</p> <pre><code>{\n \"videoId\": \"660e8400-e29b-41d4-a716-446655440001\"\n}\n</code></pre> <p>Process:</p> <ol> <li>Fetch video record from database</li> <li>Build full file path</li> <li>Run FFprobe extraction</li> <li>Update database with fresh metadata</li> <li>Mark video as valid/invalid based on result</li> </ol> <p>Typical Duration: 100-500ms per video</p>"},{"location":"v2/features/media/jobs/#re-encode-jobs-reencode_streaming","title":"Re-encode Jobs (<code>reencode_streaming</code>)","text":"<p>Purpose: Convert video to web-optimized format (H.264, web-friendly profile)</p> <p>Parameters:</p> <pre><code>{\n \"videoId\": \"660e8400-e29b-41d4-a716-446655440001\",\n \"targetBitrate\": 2000,\n \"preset\": \"medium\",\n \"crf\": 23\n}\n</code></pre> <p>FFmpeg Command:</p> <pre><code>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</code></pre> <p>Process:</p> <ol> <li>Validate input file exists</li> <li>Build FFmpeg command</li> <li>Start encoding process</li> <li>Parse FFmpeg progress output</li> <li>Update job progress every ~5%</li> <li>Create new video record for encoded file</li> <li>Update original video <code>reencodeJobId</code> reference</li> </ol> <p>Typical Duration: 5-30 minutes (depends on video length and resolution)</p>"},{"location":"v2/features/media/jobs/#compilation-jobs-compile_random-compile_quad-compile_mega","title":"Compilation Jobs (<code>compile_random</code>, <code>compile_quad</code>, <code>compile_mega</code>)","text":"<p>Purpose: Merge multiple videos into single compilation</p> <p>Parameters (Random):</p> <pre><code>{\n \"count\": 10,\n \"minDuration\": 30,\n \"maxDuration\": 120,\n \"orientation\": \"landscape\",\n \"outputPath\": \"compilations/random-001.mp4\"\n}\n</code></pre> <p>Process:</p> <ol> <li>Query database for videos matching criteria (orientation, duration range)</li> <li>Randomly select <code>count</code> videos</li> <li>Build FFmpeg concat demuxer file list</li> <li>Run FFmpeg compilation</li> <li>Create new video record for compilation</li> <li>Update progress based on FFmpeg output</li> </ol> <p>Quad Compilation (4-up grid):</p> <pre><code>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</code></pre> <p>Typical Duration: 10-60 minutes</p>"},{"location":"v2/features/media/jobs/#digest-generation-digest_generate","title":"Digest Generation (<code>digest_generate</code>)","text":"<p>Purpose: AI-powered video digest creation (future feature)</p> <p>Parameters:</p> <pre><code>{\n \"videoId\": \"660e8400-e29b-41d4-a716-446655440001\",\n \"targetLength\": 60,\n \"includeHighlights\": true\n}\n</code></pre> <p>Process (Planned):</p> <ol> <li>Extract frames at 1 FPS</li> <li>Run AI scene detection</li> <li>Identify highlights (action, faces, motion)</li> <li>Select best segments totaling target length</li> <li>Compile segments into digest video</li> </ol> <p>GPU AI Required: 8GB VRAM</p>"},{"location":"v2/features/media/jobs/#thumbnail-generation-thumbnail_generate","title":"Thumbnail Generation (<code>thumbnail_generate</code>)","text":"<p>Purpose: Extract thumbnail image from video</p> <p>Parameters:</p> <pre><code>{\n \"videoId\": \"660e8400-e29b-41d4-a716-446655440001\",\n \"timestamp\": 5,\n \"width\": 640\n}\n</code></pre> <p>FFmpeg Command:</p> <pre><code>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</code></pre> <p>Process:</p> <ol> <li>Seek to timestamp (default: 25% into video)</li> <li>Extract single frame</li> <li>Scale to width (preserve aspect ratio)</li> <li>Save as JPEG</li> <li>Update video record with <code>thumbnailPath</code></li> </ol> <p>Typical Duration: 1-5 seconds</p>"},{"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":"<pre><code>// 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</code></pre>"},{"location":"v2/features/media/jobs/#job-worker-polling-loop","title":"Job Worker (Polling Loop)","text":"<pre><code>// 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</code></pre>"},{"location":"v2/features/media/jobs/#frontend-jobs-page","title":"Frontend: Jobs Page","text":"<pre><code>// 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</code></pre>"},{"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":"<p>Symptoms:</p> <ul> <li>Jobs created but never start</li> <li>Status remains \"pending\" for hours</li> <li>No \"running\" jobs visible</li> </ul> <p>Solutions:</p> <ol> <li>Check worker process running:</li> </ol> <pre><code>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</code></pre> <ol> <li>Manually trigger worker:</li> </ol> <pre><code># Restart media-api container\ndocker compose restart media-api\n\n# Worker starts automatically on container boot\n</code></pre> <ol> <li>Check worker logs for errors:</li> </ol> <pre><code>docker compose logs -f media-api | grep ERROR\n# Look for database connection errors, permission issues\n</code></pre> <ol> <li>Verify database connection:</li> </ol> <pre><code># Test database accessible from container\ndocker compose exec media-api psql $DATABASE_URL -c \"SELECT COUNT(*) FROM jobs WHERE status='pending';\"\n</code></pre>"},{"location":"v2/features/media/jobs/#problem-job-fails-immediately","title":"Problem: Job Fails Immediately","text":"<p>Symptoms:</p> <ul> <li>Job status changes from pending \u2192 running \u2192 failed within seconds</li> <li>No meaningful progress</li> <li>Error in log: \"Command not found\" or \"Permission denied\"</li> </ul> <p>Solutions:</p> <ol> <li>Check job log in database:</li> </ol> <pre><code>SELECT log FROM jobs WHERE id = 'JOB_ID';\n</code></pre> <ol> <li>Verify FFmpeg installed:</li> </ol> <pre><code>docker compose exec media-api which ffmpeg\n# Should output: /usr/bin/ffmpeg\n\ndocker compose exec media-api ffmpeg -version\n</code></pre> <ol> <li>Check file paths valid:</li> </ol> <pre><code># 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</code></pre> <ol> <li>Test FFmpeg command manually:</li> </ol> <pre><code># 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</code></pre>"},{"location":"v2/features/media/jobs/#problem-re-encode-job-hangs-at-same-progress","title":"Problem: Re-encode Job Hangs at Same Progress","text":"<p>Symptoms:</p> <ul> <li>Job progress reaches 25%, 50%, or 75% then stops updating</li> <li>Status remains \"running\" for hours</li> <li>No CPU/GPU activity visible</li> </ul> <p>Solutions:</p> <ol> <li>Check FFmpeg process still running:</li> </ol> <pre><code>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</code></pre> <ol> <li>Kill hung FFmpeg process:</li> </ol> <pre><code>docker compose exec media-api pkill -9 ffmpeg\n\n# Job will fail and can be retried\n</code></pre> <ol> <li>Check disk space:</li> </ol> <pre><code>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</code></pre> <ol> <li>Increase FFmpeg timeout (if very large file):</li> </ol> <pre><code>// api/src/modules/media/services/job-worker.service.ts\nconst FFMPEG_TIMEOUT = 3600000; // 1 hour (from 30 minutes)\n</code></pre>"},{"location":"v2/features/media/jobs/#problem-gpu-out-of-memory-errors","title":"Problem: GPU Out of Memory Errors","text":"<p>Symptoms:</p> <ul> <li>Multiple GPU jobs running simultaneously</li> <li>Error in log: \"CUDA out of memory\" or \"Cannot allocate memory\"</li> <li>System becomes unresponsive</li> </ul> <p>Solutions:</p> <ol> <li>Check total VRAM available:</li> </ol> <pre><code>nvidia-smi\n# Shows GPU memory usage\n\n# Should show < 16GB used (adjust based on your GPU)\n</code></pre> <ol> <li>Reduce concurrent GPU job limit:</li> </ol> <pre><code>// 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</code></pre> <ol> <li>Increase VRAM requirements for jobs:</li> </ol> <pre><code>// 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</code></pre> <ol> <li>Kill running GPU jobs:</li> </ol> <pre><code># 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</code></pre>"},{"location":"v2/features/media/jobs/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/features/media/jobs/#job-queue-throughput","title":"Job Queue Throughput","text":"<p>Scaling Factors:</p> <ul> <li>CPU jobs: 5 concurrent = ~10-20 jobs/minute (scans, validations)</li> <li>GPU encode: 2 concurrent = ~4-8 videos/hour (depends on length)</li> <li>GPU AI: 1 concurrent = ~2-6 videos/hour (depends on complexity)</li> </ul> <p>Bottlenecks:</p> <ol> <li>GPU Memory \u2014 Limits concurrent GPU jobs</li> <li>Disk I/O \u2014 Reading/writing large video files</li> <li>CPU \u2014 FFmpeg encoding uses all available cores</li> </ol> <p>Optimization:</p> <ul> <li>Distribute workers across multiple machines \u2014 Each machine runs separate worker process</li> <li>Use job priority \u2014 Urgent jobs (priority 1-3) run first</li> <li>Batch similar jobs \u2014 Group scan jobs, re-encode jobs, etc. for efficiency</li> </ul>"},{"location":"v2/features/media/jobs/#database-performance","title":"Database Performance","text":"<p>Job Queue Index:</p> <pre><code>CREATE INDEX idx_jobs_status_priority ON jobs(status, priority, created_at);\n</code></pre> <p>Query Performance:</p> <ul> <li>Find next pending job: ~1-5ms (with index)</li> <li>Update job status: ~2-10ms</li> <li>Fetch job logs: ~5-20ms</li> </ul> <p>Optimization:</p> <ul> <li>Partition jobs table by date \u2014 Move old completed/failed jobs to archive table</li> <li>Limit log size \u2014 Truncate logs > 10KB to prevent bloat</li> </ul>"},{"location":"v2/features/media/jobs/#monitoring-observability","title":"Monitoring & Observability","text":""},{"location":"v2/features/media/jobs/#prometheus-metrics","title":"Prometheus Metrics","text":"<pre><code>// 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</code></pre>"},{"location":"v2/features/media/jobs/#grafana-dashboard-panel","title":"Grafana Dashboard Panel","text":"<p>Job Queue Status:</p> <pre><code># 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</code></pre> <p>Alert Rules:</p> <pre><code># 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</code></pre>"},{"location":"v2/features/media/jobs/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/features/media/jobs/#backend-documentation","title":"Backend Documentation","text":"<ul> <li>Job Worker: <code>backend/modules/media/job-worker.md</code> \u2014 Worker process implementation</li> <li>Job Processors: <code>backend/modules/media/processors/</code> \u2014 Individual job type processors (reencode, scan, etc.)</li> <li>Jobs Routes: <code>backend/modules/media/jobs.md</code> \u2014 API endpoints for job management</li> </ul>"},{"location":"v2/features/media/jobs/#frontend-documentation","title":"Frontend Documentation","text":"<ul> <li>Jobs Page: <code>frontend/pages/media/jobs.md</code> \u2014 Job queue monitoring UI</li> <li>Job Detail Modal: <code>frontend/components/media/job-detail.md</code> \u2014 Log viewer component</li> </ul>"},{"location":"v2/features/media/jobs/#feature-documentation","title":"Feature Documentation","text":"<ul> <li>Video Library: <code>features/media/video-library.md</code> \u2014 Triggering jobs from library actions</li> <li>Upload System: <code>features/media/upload.md</code> \u2014 Post-upload job creation</li> </ul>"},{"location":"v2/features/media/jobs/#next-steps","title":"Next Steps","text":"<p>After mastering the job queue:</p> <ol> <li>Create Custom Jobs \u2014 Implement new job types for domain-specific processing</li> <li>Optimize Scheduling \u2014 Tune resource limits and priority settings for your workload</li> <li>Monitor Performance \u2014 Set up Grafana dashboards and alerts for job queue health</li> <li>Distributed Workers \u2014 Scale horizontally by running workers on multiple machines</li> </ol> <p>Hands-On Practice:</p> <pre><code># 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</code></pre> <p>Last Updated: 2026-02-13 Version: V2.0 Maintainer: Changemaker Lite Team</p>"},{"location":"v2/features/media/public-gallery/","title":"Public Video Gallery","text":""},{"location":"v2/features/media/public-gallery/#overview","title":"Overview","text":"<p>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.</p> <p>Key Features:</p> <ul> <li>Public Access \u2014 No login required, SEO-friendly URLs</li> <li>Category Organization \u2014 Browse by Entertainment, Education, Sports, News, etc.</li> <li>Lock/Unlock System \u2014 Admins control which videos are public via Shared Media page</li> <li>Reaction System \u2014 6 emoji reactions (Like, Love, Laugh, Surprise, Sad, Angry)</li> <li>Comment System \u2014 Visitor comments with name/email (moderation pending)</li> <li>View Tracking \u2014 Track total views + watch time per video</li> <li>Upvote System \u2014 Visitors upvote favorite videos (ranking algorithm)</li> <li>Related Videos \u2014 Show 3 similar videos below player</li> <li>Responsive Design \u2014 Mobile-friendly grid layout</li> <li>Video Player \u2014 HTML5 player with controls, fullscreen, playback speed</li> <li>Social Sharing \u2014 Share video URLs on social media</li> </ul> <p>Access Control:</p> <ul> <li>Public Routes \u2014 No authentication required</li> <li>Admin Control \u2014 Shared Media page (SUPER_ADMIN only) controls which videos are public</li> <li>Unlocking Videos \u2014 Removes from public gallery (not deleted, just hidden)</li> </ul> <p>Technology Stack:</p> <ul> <li>Frontend: React + Ant Design + react-player</li> <li>Backend: Fastify media API public routes (no auth)</li> <li>Caching: Redis for public video lists (5 min TTL)</li> <li>SEO: Server-side meta tags, sitemap generation</li> </ul>"},{"location":"v2/features/media/public-gallery/#architecture","title":"Architecture","text":"<pre><code>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</code></pre> <p>Workflow:</p> <ol> <li>Admin Shares Video \u2014 Admin clicks \"Share\" button on SharedMediaPage \u2192 video marked public</li> <li>Public Browse \u2014 Visitor navigates to /media \u2192 sees grid of public videos</li> <li>Video Player \u2014 Visitor clicks video card \u2192 opens /media/:id \u2192 player page</li> <li>Engagement \u2014 Visitor reacts, comments, or shares video</li> <li>View Tracking \u2014 Frontend tracks watch time, sends to API on pause/end</li> <li>Related Videos \u2014 API suggests 3 similar videos (same category/creator)</li> </ol>"},{"location":"v2/features/media/public-gallery/#database-models","title":"Database Models","text":""},{"location":"v2/features/media/public-gallery/#videos-table-public-fields","title":"Videos Table (Public Fields)","text":"<pre><code>// 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</code></pre> <p>Privacy: Never expose <code>path</code>, <code>filename</code>, <code>fileHash</code>, or internal metadata publicly.</p>"},{"location":"v2/features/media/public-gallery/#reactions-table","title":"Reactions Table","text":"<pre><code>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</code></pre> <p>Reaction Types:</p> <ul> <li>\ud83d\udc4d <code>like</code> \u2014 General approval</li> <li>\u2764\ufe0f <code>love</code> \u2014 Strong positive emotion</li> <li>\ud83d\ude02 <code>laugh</code> \u2014 Funny/amusing</li> <li>\ud83d\ude2e <code>surprise</code> \u2014 Surprising/shocking</li> <li>\ud83d\ude22 <code>sad</code> \u2014 Sad/emotional</li> <li>\ud83d\ude20 <code>angry</code> \u2014 Frustrating/angering</li> </ul> <p>Session Tracking:</p> <pre><code>// 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</code></pre>"},{"location":"v2/features/media/public-gallery/#comments-table","title":"Comments Table","text":"<pre><code>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</code></pre> <p>Moderation Workflow:</p> <ol> <li>User submits comment \u2192 stored with <code>approved = false</code></li> <li>Admin reviews comment in moderation dashboard</li> <li>Admin clicks \"Approve\" \u2192 <code>approved = true</code>, comment visible</li> <li>Admin clicks \"Reject\" \u2192 comment remains hidden or deleted</li> </ol>"},{"location":"v2/features/media/public-gallery/#view-logs-table","title":"View Logs Table","text":"<pre><code>CREATE TABLE video_view_logs (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n video_id UUID NOT NULL REFERENCES videos(id),\n session_id TEXT NOT NULL,\n watch_time_seconds INTEGER DEFAULT 0, -- Actual watch time (not video duration)\n completed BOOLEAN DEFAULT FALSE, -- Watched > 90%\n created_at TIMESTAMP DEFAULT NOW()\n);\n\nCREATE INDEX idx_view_logs_video ON video_view_logs(video_id);\nCREATE INDEX idx_view_logs_session ON video_view_logs(session_id, video_id);\n</code></pre> <p>Watch Time Tracking:</p> <pre><code>// 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</code></pre>"},{"location":"v2/features/media/public-gallery/#api-endpoints-public","title":"API Endpoints (Public)","text":"<p>All endpoints are public (no authentication required).</p>"},{"location":"v2/features/media/public-gallery/#list-public-videos","title":"List Public Videos","text":"<pre><code>GET /api/public/media\n</code></pre> <p>Query Parameters:</p> Parameter Type Default Description <code>page</code> number 1 Page number <code>limit</code> number 24 Results per page <code>category</code> string - Filter by category <code>orientation</code> string - Filter by orientation (portrait/landscape/square) <code>quality</code> string - Filter by quality (SD/HD/FHD/UHD) <code>sort</code> string recent Sort by: recent, popular, trending <p>Response:</p> <pre><code>{\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</code></pre> <p>Caching:</p> <pre><code>// 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</code></pre>"},{"location":"v2/features/media/public-gallery/#get-video-details","title":"Get Video Details","text":"<pre><code>GET /api/public/media/:id\n</code></pre> <p>Response:</p> <pre><code>{\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</code></pre> <p>Related Videos Algorithm:</p> <pre><code>// 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</code></pre>"},{"location":"v2/features/media/public-gallery/#track-video-view","title":"Track Video View","text":"<pre><code>POST /api/public/media/:id/view\n</code></pre> <p>Request Body:</p> <pre><code>{\n \"watchTimeSeconds\": 120,\n \"completed\": true\n}\n</code></pre> <p>Response:</p> <pre><code>{\n \"success\": true,\n \"newViewCount\": 1252\n}\n</code></pre> <p>Process:</p> <ol> <li>Get session ID (IP hash or cookie)</li> <li>Check if already viewed in last 24 hours (prevent duplicate counting)</li> <li>Create view log record</li> <li>Increment video <code>publicViewCount</code></li> <li>Return new view count</li> </ol>"},{"location":"v2/features/media/public-gallery/#addupdate-reaction","title":"Add/Update Reaction","text":"<pre><code>POST /api/public/media/:id/reaction\n</code></pre> <p>Request Body:</p> <pre><code>{\n \"reactionType\": \"like\"\n}\n</code></pre> <p>Response:</p> <pre><code>{\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</code></pre> <p>Process:</p> <ol> <li>Get session ID</li> <li>Check if user already reacted</li> <li>If same reaction, remove it (toggle off)</li> <li>If different reaction, update it</li> <li>If no reaction, insert new one</li> <li>Return updated reaction counts</li> </ol>"},{"location":"v2/features/media/public-gallery/#submit-comment","title":"Submit Comment","text":"<pre><code>POST /api/public/media/:id/comment\n</code></pre> <p>Request Body:</p> <pre><code>{\n \"name\": \"John Doe\",\n \"email\": \"john@example.com\",\n \"comment\": \"This video is amazing! Thanks for sharing.\"\n}\n</code></pre> <p>Response:</p> <pre><code>{\n \"success\": true,\n \"message\": \"Comment submitted for moderation\"\n}\n</code></pre> <p>Validation:</p> <ul> <li>Name: 1-100 characters</li> <li>Email: Optional, valid email format</li> <li>Comment: 1-1000 characters, no HTML allowed</li> </ul> <p>Anti-Spam:</p> <ul> <li>Rate limit: 5 comments per hour per session</li> <li>Duplicate detection: reject if same comment in last 24 hours</li> </ul>"},{"location":"v2/features/media/public-gallery/#admin-workflow","title":"Admin Workflow","text":""},{"location":"v2/features/media/public-gallery/#sharing-videos-making-public","title":"Sharing Videos (Making Public)","text":"<ol> <li>Navigate to Media \u2192 Shared Media page</li> <li>Table shows all videos with \"Public\" toggle switch</li> <li>To share video:</li> <li>Click toggle switch to ON (blue)</li> <li>Video immediately appears in public gallery</li> <li>Modal prompts for category selection (optional)</li> <li>To unshare video:</li> <li>Click toggle switch to OFF (grey)</li> <li>Video removed from public gallery</li> <li><code>movedFromPublicAt</code> timestamp set (preserves history)</li> </ol> <p>Shared Media Page Features:</p> <ul> <li>Category Management \u2014 Assign videos to categories (Entertainment, Education, Sports, etc.)</li> <li>Bulk Actions \u2014 Select multiple videos, share/unshare all at once</li> <li>Preview \u2014 Click \"Preview\" button to see public view</li> <li>Stats \u2014 View count, upvote count, reaction breakdown</li> <li>Lock Indicator \u2014 Icon shows which videos are currently public</li> </ul>"},{"location":"v2/features/media/public-gallery/#setting-categories","title":"Setting Categories","text":"<p>Option 1: Tag-Based Categories</p> <p>Use video tags to auto-assign categories:</p> <pre><code>// 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</code></pre> <p>Option 2: Manual Assignment</p> <ol> <li>Select video in Shared Media page</li> <li>Click \"Edit Category\" button</li> <li>Modal opens with category dropdown:</li> <li>Entertainment</li> <li>Education</li> <li>Sports</li> <li>News</li> <li>Music</li> <li>Gaming</li> <li>Science & Tech</li> <li>Travel</li> <li>Other</li> <li>Click \"Save\"</li> <li>Category updated immediately</li> </ol>"},{"location":"v2/features/media/public-gallery/#viewing-statistics","title":"Viewing Statistics","text":"<p>Per-Video Stats:</p> <ol> <li>Click video row in Shared Media page</li> <li>Stats drawer slides in from right showing:</li> <li>Total Views \u2014 All-time view count</li> <li>Average Watch Time \u2014 Mean watch time (seconds)</li> <li>Completion Rate \u2014 % of viewers who watched > 90%</li> <li>Upvotes \u2014 Total upvote count</li> <li>Reactions Breakdown \u2014 Chart showing reaction distribution</li> <li>Top Referrers \u2014 Where views came from (direct, social, etc.)</li> <li>View Trend \u2014 Line chart of views over last 30 days</li> </ol> <p>Gallery-Wide Stats:</p> <p>Dashboard widget showing:</p> <ul> <li>Total public videos</li> <li>Total views across all videos</li> <li>Most popular video (by views)</li> <li>Trending video (highest growth rate)</li> <li>Total reactions</li> <li>Total comments (pending + approved)</li> </ul>"},{"location":"v2/features/media/public-gallery/#moderating-comments","title":"Moderating Comments","text":"<ol> <li>Navigate to Media \u2192 Comments page (or notification badge in sidebar)</li> <li>Table shows all comments with filters:</li> <li>Pending \u2014 Awaiting moderation</li> <li>Approved \u2014 Visible on public gallery</li> <li>Rejected \u2014 Hidden from public</li> <li>To approve comment:</li> <li>Click \"Approve\" button</li> <li>Comment appears on video page immediately</li> <li>To reject comment:</li> <li>Click \"Reject\" button</li> <li>Comment hidden (or deleted)</li> <li>Optional: Send email to commenter explaining why</li> </ol> <p>Bulk Moderation:</p> <ul> <li>Select multiple comments via checkboxes</li> <li>Click \"Approve All\" or \"Reject All\"</li> <li>Batch updates applied instantly</li> </ul>"},{"location":"v2/features/media/public-gallery/#public-user-workflow","title":"Public User Workflow","text":""},{"location":"v2/features/media/public-gallery/#browsing-gallery","title":"Browsing Gallery","text":"<ol> <li>Navigate to https://cmlite.org/media</li> <li>Hero section shows featured video (most popular or admin-selected)</li> <li>Category tabs below hero:</li> <li>All</li> <li>Entertainment</li> <li>Education</li> <li>Sports</li> <li>News</li> <li>Music</li> <li>Gaming</li> <li>Science & Tech</li> <li>Grid of video cards (4 per row on desktop, 2 on tablet, 1 on mobile)</li> <li>Each card shows:</li> <li>Thumbnail image</li> <li>Title</li> <li>Producer/creator</li> <li>Duration badge</li> <li>View count</li> <li>Quality badge (HD, FHD, UHD)</li> </ol> <p>Infinite Scroll:</p> <ul> <li>As user scrolls to bottom, next page loads automatically</li> <li>Loading spinner shows while fetching</li> <li>No \"Load More\" button needed</li> </ul>"},{"location":"v2/features/media/public-gallery/#watching-video","title":"Watching Video","text":"<ol> <li>Click video card \u2192 navigates to https://cmlite.org/media/:id</li> <li>Video player page layout:</li> <li>Video Player \u2014 Full-width HTML5 player with controls</li> <li>Video Title & Metadata \u2014 Title, producer, creator, view count</li> <li>Reaction Bar \u2014 6 emoji buttons with counts</li> <li>Description \u2014 Auto-generated or admin-provided</li> <li>Comments Section \u2014 Approved comments + submit form</li> <li>Related Videos \u2014 3 similar videos in sidebar</li> <li>User clicks play \u2192 video starts, watch time tracked</li> <li>User clicks reaction \u2192 emoji highlighted, count increments</li> <li>User scrolls to comments \u2192 reads existing, submits new</li> </ol> <p>Video Player Features:</p> <ul> <li>Play/pause button</li> <li>Volume slider</li> <li>Playback speed (0.5x, 1x, 1.25x, 1.5x, 2x)</li> <li>Fullscreen button</li> <li>Current time / total duration</li> <li>Scrub bar (seek to any position)</li> <li>Auto-play next related video (optional)</li> </ul>"},{"location":"v2/features/media/public-gallery/#reacting-to-video","title":"Reacting to Video","text":"<ol> <li>Click reaction emoji button (e.g., \ud83d\udc4d Like)</li> <li>Button highlights in color</li> <li>Count increments by 1</li> <li>Toggle behavior:</li> <li>Click again \u2192 removes reaction, count decrements</li> <li>Click different emoji \u2192 switches reaction</li> <li>Session tracked via cookie (reactions persist across page refreshes)</li> </ol> <p>Reaction Colors:</p> <ul> <li>Like \ud83d\udc4d \u2014 Blue</li> <li>Love \u2764\ufe0f \u2014 Red</li> <li>Laugh \ud83d\ude02 \u2014 Yellow</li> <li>Surprise \ud83d\ude2e \u2014 Purple</li> <li>Sad \ud83d\ude22 \u2014 Grey</li> <li>Angry \ud83d\ude20 \u2014 Orange</li> </ul>"},{"location":"v2/features/media/public-gallery/#commenting","title":"Commenting","text":"<ol> <li>Scroll to comments section below video</li> <li>Fill out form:</li> <li>Name \u2014 Required, displayed publicly</li> <li>Email \u2014 Optional, for moderation notifications</li> <li>Comment \u2014 Required, 1-1000 characters</li> <li>Click \"Submit Comment\"</li> <li>Success message: \"Comment submitted for moderation\"</li> <li>Comment appears in list with \"Pending approval\" badge</li> <li>After admin approval, comment visible to all</li> </ol> <p>Comment Formatting:</p> <ul> <li>Plain text only (no HTML)</li> <li>URLs auto-linked</li> <li>Line breaks preserved</li> <li>Profanity filter applied (optional)</li> </ul>"},{"location":"v2/features/media/public-gallery/#code-examples","title":"Code Examples","text":""},{"location":"v2/features/media/public-gallery/#backend-list-public-videos","title":"Backend: List Public Videos","text":"<pre><code>// 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</code></pre>"},{"location":"v2/features/media/public-gallery/#backend-track-view","title":"Backend: Track View","text":"<pre><code>// 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</code></pre>"},{"location":"v2/features/media/public-gallery/#backend-add-reaction","title":"Backend: Add Reaction","text":"<pre><code>// 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</code></pre>"},{"location":"v2/features/media/public-gallery/#frontend-video-gallery-page","title":"Frontend: Video Gallery Page","text":"<pre><code>// 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</code></pre>"},{"location":"v2/features/media/public-gallery/#frontend-video-player-page","title":"Frontend: Video Player Page","text":"<pre><code>// 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</code></pre>"},{"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":"<p>Symptoms:</p> <ul> <li>SharedMediaPage shows videos marked as public</li> <li>Public gallery shows \"No videos found\"</li> <li>API returns empty array</li> </ul> <p>Solutions:</p> <ol> <li>Check <code>movedFromPublicAt</code> field:</li> </ol> <pre><code>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</code></pre> <ol> <li>Verify <code>isValid = true</code>:</li> </ol> <pre><code>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</code></pre> <ol> <li>Check Redis cache:</li> </ol> <pre><code># Clear public video cache\ndocker compose exec redis redis-cli\n> KEYS public:videos:*\n> DEL public:videos:*\n\n# Refresh gallery page\n</code></pre> <ol> <li>Test API directly:</li> </ol> <pre><code>curl http://localhost:4100/api/public/media\n# Should return JSON with videos array\n</code></pre>"},{"location":"v2/features/media/public-gallery/#problem-reactions-not-saving","title":"Problem: Reactions Not Saving","text":"<p>Symptoms:</p> <ul> <li>Click reaction button, count doesn't increment</li> <li>Refresh page, reaction disappears</li> <li>No errors in console</li> </ul> <p>Solutions:</p> <ol> <li>Check session ID generation:</li> </ol> <pre><code>// 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</code></pre> <ol> <li>Verify database insert:</li> </ol> <pre><code>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</code></pre> <ol> <li>Test reaction endpoint:</li> </ol> <pre><code>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</code></pre>"},{"location":"v2/features/media/public-gallery/#problem-comments-not-showing-after-approval","title":"Problem: Comments Not Showing After Approval","text":"<p>Symptoms:</p> <ul> <li>Admin approves comment</li> <li>Comment still doesn't appear on video page</li> <li>Database shows <code>approved = true</code></li> </ul> <p>Solutions:</p> <ol> <li>Check query filter:</li> </ol> <pre><code>// 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</code></pre> <ol> <li>Clear cache:</li> </ol> <pre><code># Video details may be cached\ndocker compose exec redis redis-cli DEL \"public:video:VIDEO_ID\"\n</code></pre> <ol> <li>Verify approval:</li> </ol> <pre><code>SELECT id, comment, approved FROM video_comments WHERE video_id = 'VIDEO_ID';\n-- Should show approved = true\n</code></pre>"},{"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":"<p>Cache Keys:</p> <ul> <li><code>public:videos:{query}</code> \u2014 List of videos (5 min TTL)</li> <li><code>public:video:{id}</code> \u2014 Video details (10 min TTL)</li> <li><code>public:stats</code> \u2014 Gallery-wide stats (15 min TTL)</li> </ul> <p>Cache Invalidation:</p> <pre><code>// 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</code></pre>"},{"location":"v2/features/media/public-gallery/#database-indexes","title":"Database Indexes","text":"<pre><code>-- 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</code></pre>"},{"location":"v2/features/media/public-gallery/#seo-optimization","title":"SEO Optimization","text":"<p>Server-Side Rendering (Future):</p> <pre><code>// 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</code></pre> <p>Meta Tags:</p> <pre><code><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</code></pre> <p>Sitemap Generation:</p> <pre><code><?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</code></pre>"},{"location":"v2/features/media/public-gallery/#security-considerations","title":"Security Considerations","text":""},{"location":"v2/features/media/public-gallery/#rate-limiting","title":"Rate Limiting","text":"<pre><code>// 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</code></pre> <p>Per-Endpoint Limits:</p> <ul> <li>List videos: 100/min</li> <li>Video details: 100/min</li> <li>Track view: 10/min (prevent view count manipulation)</li> <li>Add reaction: 20/min</li> <li>Submit comment: 5/hour (anti-spam)</li> </ul>"},{"location":"v2/features/media/public-gallery/#content-moderation","title":"Content Moderation","text":"<p>Comment Filtering:</p> <pre><code>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</code></pre> <p>Spam Detection:</p> <pre><code>// 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</code></pre>"},{"location":"v2/features/media/public-gallery/#privacy-protection","title":"Privacy Protection","text":"<p>Never Expose:</p> <ul> <li>Internal file paths (<code>/media/local/library/...</code>)</li> <li>Original filenames (use video ID for playback URL)</li> <li>Admin user information</li> <li>Email addresses from comments (unless user explicitly made public)</li> </ul> <p>Session Tracking:</p> <pre><code>// 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</code></pre>"},{"location":"v2/features/media/public-gallery/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/features/media/public-gallery/#backend-documentation","title":"Backend Documentation","text":"<ul> <li>Public Routes: <code>backend/modules/media/public.md</code> \u2014 Public API endpoints</li> <li>Reactions Service: <code>backend/modules/media/reactions.md</code> \u2014 Reaction system implementation</li> <li>Comments Service: <code>backend/modules/media/comments.md</code> \u2014 Comment moderation system</li> </ul>"},{"location":"v2/features/media/public-gallery/#frontend-documentation","title":"Frontend Documentation","text":"<ul> <li>Media Gallery Page: <code>frontend/pages/public/media-gallery.md</code> \u2014 Gallery UI implementation</li> <li>Video Player Page: <code>frontend/pages/public/media-viewer.md</code> \u2014 Player component</li> </ul>"},{"location":"v2/features/media/public-gallery/#feature-documentation","title":"Feature Documentation","text":"<ul> <li>Video Library: <code>features/media/video-library.md</code> \u2014 Admin video management</li> <li>Shared Media: <code>features/media/shared-media.md</code> \u2014 Sharing controls (admin)</li> </ul>"},{"location":"v2/features/media/public-gallery/#next-steps","title":"Next Steps","text":"<p>After mastering the public gallery:</p> <ol> <li>Analytics Dashboard \u2014 Build admin dashboard showing view trends, popular videos, engagement metrics</li> <li>Playlist System \u2014 Allow users to create and share playlists</li> <li>Video Embedding \u2014 Generate embed codes for external websites</li> <li>Advanced Search \u2014 Full-text search across titles, producers, creators, tags</li> </ol> <p>Hands-On Practice:</p> <pre><code># 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</code></pre> <p>Last Updated: 2026-02-13 Version: V2.0 Maintainer: Changemaker Lite Team</p>"},{"location":"v2/features/media/upload/","title":"Video Upload System","text":""},{"location":"v2/features/media/upload/#overview","title":"Overview","text":"<p>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.</p> <p>Key Features:</p> <ul> <li>Drag-and-Drop Interface \u2014 Intuitive file selection with visual drop zone</li> <li>Automatic Metadata Extraction \u2014 FFprobe extracts duration, dimensions, orientation, quality, and audio detection</li> <li>Single & Batch Upload \u2014 Upload one video or queue multiple files</li> <li>Large File Support \u2014 Handles files up to 10GB via streaming (no memory buffering)</li> <li>Progress Tracking \u2014 Real-time upload progress with percentage and speed</li> <li>Format Validation \u2014 Supports MP4, MOV, AVI, MKV, WebM, M4V, FLV</li> <li>UUID Filenames \u2014 Prevents conflicts and path traversal attacks</li> <li>Inbox Staging \u2014 Videos uploaded to <code>/inbox</code> directory before processing</li> <li>Manual Metadata \u2014 Admin can override auto-detected fields (producer, creator, title, tags)</li> </ul> <p>Technology Stack:</p> <ul> <li>Frontend: Ant Design Upload component with custom drag-drop styling</li> <li>Backend: Fastify @fastify/multipart plugin for streaming uploads</li> <li>Metadata: FFprobe for video analysis (duration, dimensions, codec, bitrate)</li> <li>Storage: Direct filesystem writes to <code>/media/local/inbox</code> directory</li> </ul>"},{"location":"v2/features/media/upload/#architecture","title":"Architecture","text":"<pre><code>sequenceDiagram\n participant U as User\n participant UI as UploadVideoModal\n participant API as Fastify Media API\n participant FS as Filesystem\n participant FFP as FFprobe Service\n participant DB as PostgreSQL\n\n U->>UI: Drag video file(s)\n UI->>UI: Validate file type/size\n UI->>U: Show file in queue\n\n U->>UI: Click \"Upload\"\n UI->>API: POST /api/media/upload/single<br/>(multipart/form-data)\n\n API->>API: Generate UUID filename\n API->>FS: Stream to /inbox/{uuid}.mp4\n FS-->>API: Write complete\n\n API->>FFP: Extract metadata\n FFP->>FS: Analyze video file\n FFP-->>API: Return metadata JSON\n\n API->>DB: INSERT video record\n DB-->>API: Return video ID\n\n API-->>UI: Upload success + metadata\n UI-->>U: Show success message\n UI->>UI: Refresh library table\n\n Note over API,FS: File remains in /inbox<br/>until moved by admin</code></pre> <p>Upload Flow:</p> <ol> <li>Client Validation \u2014 Browser checks file extension and size before upload</li> <li>Streaming Upload \u2014 File streamed to disk in chunks (no memory buffer)</li> <li>Metadata Extraction \u2014 FFprobe analyzes video (30s timeout)</li> <li>Database Record \u2014 Video record created with auto-detected metadata</li> <li>Response \u2014 Frontend receives video ID and metadata</li> <li>Library Update \u2014 Table refreshes to show new video</li> </ol> <p>Key Design Decisions:</p> <ul> <li>Streaming vs Buffering \u2014 Streaming prevents memory exhaustion on large files (10GB would require 10GB RAM if buffered)</li> <li>Inbox Staging \u2014 New uploads go to <code>/inbox</code> directory instead of final location, allowing admin review before publishing</li> <li>UUID Filenames \u2014 Prevents filename conflicts and path traversal attacks (<code>../../etc/passwd.mp4</code>)</li> <li>Synchronous FFprobe \u2014 Metadata extracted immediately (not deferred to job queue) for instant feedback</li> </ul>"},{"location":"v2/features/media/upload/#upload-workflow","title":"Upload Workflow","text":""},{"location":"v2/features/media/upload/#user-workflow-admin","title":"User Workflow (Admin)","text":"<ol> <li>Open Upload Modal</li> <li>Navigate to Media \u2192 Library page</li> <li>Click \"Upload Video\" button in top toolbar</li> <li> <p>Modal opens with drag-drop zone</p> </li> <li> <p>Select Files</p> </li> <li>Drag files from desktop into blue dashed zone</li> <li>OR click \"Click to browse\" link to open file picker</li> <li> <p>Multiple files can be selected for batch upload</p> </li> <li> <p>Review Queue</p> </li> <li>Selected files appear in list with:<ul> <li>Filename and size</li> <li>File type icon</li> <li>Remove button (X)</li> </ul> </li> <li> <p>Invalid files (wrong extension, too large) highlighted in red</p> </li> <li> <p>Enter Metadata (Optional)</p> </li> <li>Producer \u2014 Studio or production company name</li> <li>Creator \u2014 Director or primary creator</li> <li>Title \u2014 Display title (defaults to filename if blank)</li> <li> <p>Tags \u2014 Comma-separated tags (e.g., \"action, sports, highlight\")</p> </li> <li> <p>Upload</p> </li> <li>Click \"Upload\" button</li> <li>Files upload sequentially (not parallel)</li> <li> <p>Progress bar shows:</p> <ul> <li>Current file name</li> <li>Upload percentage (0-100%)</li> <li>Upload speed (MB/s)</li> <li>Estimated time remaining</li> </ul> </li> <li> <p>Metadata Extraction</p> </li> <li>After upload completes, FFprobe runs automatically</li> <li>Spinner shows \"Extracting metadata...\"</li> <li> <p>Auto-fills: duration, dimensions, orientation, quality, audio</p> </li> <li> <p>Success</p> </li> <li>Green checkmark appears</li> <li>Success message: \"Uploaded: {filename}\"</li> <li>Modal can be closed or kept open for more uploads</li> <li>Library table refreshes showing new video</li> </ol>"},{"location":"v2/features/media/upload/#error-handling","title":"Error Handling","text":"<p>Invalid File Type:</p> <pre><code>Error: File type not supported\nAllowed: MP4, MOV, AVI, MKV, WebM, M4V, FLV\n</code></pre> <p>File Too Large:</p> <pre><code>Error: File exceeds 10GB limit\nSelected file: 12.5 GB\n</code></pre> <p>Upload Failed:</p> <pre><code>Error: Upload failed\nNetwork error or server unavailable\n</code></pre> <p>FFprobe Extraction Failed:</p> <pre><code>Warning: Metadata extraction failed\nVideo uploaded but metadata incomplete\nYou can manually enter duration and dimensions\n</code></pre>"},{"location":"v2/features/media/upload/#api-endpoints","title":"API Endpoints","text":""},{"location":"v2/features/media/upload/#upload-single-video","title":"Upload Single Video","text":"<pre><code>POST /api/media/upload/single\nContent-Type: multipart/form-data\nAuthorization: Bearer <admin_token>\n</code></pre> <p>Request (Multipart Form Data):</p> <pre><code>--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</code></pre> <p>Response (Success):</p> <pre><code>{\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</code></pre> <p>Response (Error):</p> <pre><code>{\n \"statusCode\": 400,\n \"error\": \"Bad Request\",\n \"message\": \"Invalid file type. Allowed: mp4, mov, avi, mkv, webm, m4v, flv\"\n}\n</code></pre>"},{"location":"v2/features/media/upload/#upload-batch-multiple-videos","title":"Upload Batch (Multiple Videos)","text":"<pre><code>POST /api/media/upload/batch\nContent-Type: multipart/form-data\nAuthorization: Bearer <admin_token>\n</code></pre> <p>Request:</p> <pre><code>--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</code></pre> <p>Response:</p> <pre><code>{\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</code></pre>"},{"location":"v2/features/media/upload/#configuration","title":"Configuration","text":""},{"location":"v2/features/media/upload/#environment-variables","title":"Environment Variables","text":"<pre><code># 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</code></pre>"},{"location":"v2/features/media/upload/#fastify-multipart-configuration","title":"Fastify Multipart Configuration","text":"<pre><code>// 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</code></pre>"},{"location":"v2/features/media/upload/#docker-volume-mounts","title":"Docker Volume Mounts","text":"<p>Critical: Inbox directory must be mounted as read-write (<code>:rw</code>):</p> <pre><code># 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</code></pre> <p>Without <code>:rw</code> suffix, uploads fail with permission errors.</p>"},{"location":"v2/features/media/upload/#code-examples","title":"Code Examples","text":""},{"location":"v2/features/media/upload/#frontend-upload-modal-component","title":"Frontend: Upload Modal Component","text":"<pre><code>// 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</code></pre>"},{"location":"v2/features/media/upload/#backend-single-upload-route","title":"Backend: Single Upload Route","text":"<pre><code>// 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</code></pre>"},{"location":"v2/features/media/upload/#ffprobe-metadata-extraction","title":"FFprobe Metadata Extraction","text":"<pre><code>// 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</code></pre>"},{"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":"<p>Symptoms:</p> <ul> <li>Upload progress reaches 100% then fails</li> <li>Error message: \"File exceeds maximum size\"</li> <li>Browser console shows 413 Payload Too Large</li> </ul> <p>Solutions:</p> <ol> <li>Check file size:</li> </ol> <pre><code># 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</code></pre> <ol> <li>Verify Fastify limit:</li> </ol> <pre><code>// api/src/media-server.ts\napp.register(multipart, {\n limits: {\n fileSize: 10 * 1024 * 1024 * 1024, // 10GB\n },\n});\n</code></pre> <ol> <li>Check nginx client_max_body_size:</li> </ol> <pre><code># nginx/nginx.conf or nginx/conf.d/api.conf\nclient_max_body_size 10G;\n</code></pre> <ol> <li>Increase timeout for large files:</li> </ol> <pre><code># 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</code></pre>"},{"location":"v2/features/media/upload/#problem-ffprobe-metadata-extraction-fails","title":"Problem: FFprobe Metadata Extraction Fails","text":"<p>Symptoms:</p> <ul> <li>Upload succeeds but metadata fields null</li> <li>Warning: \"Metadata extraction failed\"</li> <li>Duration, dimensions missing in library</li> </ul> <p>Solutions:</p> <ol> <li>Check FFmpeg installed:</li> </ol> <pre><code>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</code></pre> <ol> <li>Install FFmpeg if missing:</li> </ol> <pre><code># api/Dockerfile.media\nFROM node:20-alpine\n\n# Install FFmpeg\nRUN apk add --no-cache ffmpeg\n\n# ... rest of Dockerfile\n</code></pre> <pre><code># Rebuild container\ndocker compose build media-api\ndocker compose up -d media-api\n</code></pre> <ol> <li>Test FFprobe manually:</li> </ol> <pre><code># 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</code></pre> <ol> <li>Check video file not corrupt:</li> </ol> <pre><code># 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</code></pre> <ol> <li>Increase timeout for large files:</li> </ol> <pre><code># .env\nFFPROBE_TIMEOUT=60000 # 60 seconds (from 30)\n</code></pre>"},{"location":"v2/features/media/upload/#problem-upload-hangs-at-100","title":"Problem: Upload Hangs at 100%","text":"<p>Symptoms:</p> <ul> <li>Progress bar reaches 100% but never completes</li> <li>No success or error message</li> <li>Browser tab freezes</li> </ul> <p>Solutions:</p> <ol> <li>Check nginx proxy timeout:</li> </ol> <pre><code># 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</code></pre> <ol> <li>Verify disk space available:</li> </ol> <pre><code>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</code></pre> <ol> <li>Check backend logs:</li> </ol> <pre><code>docker compose logs -f media-api | grep upload\n# Look for errors or timeouts\n</code></pre> <ol> <li>Test with smaller file:</li> </ol> <pre><code># 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</code></pre>"},{"location":"v2/features/media/upload/#problem-inbox-directory-not-writable","title":"Problem: Inbox Directory Not Writable","text":"<p>Symptoms:</p> <ul> <li>Upload fails with \"Permission denied\"</li> <li>Error: \"EACCES: permission denied, open '/media/local/inbox/...'\"</li> <li>Upload never starts</li> </ul> <p>Solutions:</p> <ol> <li>Check Docker volume mount:</li> </ol> <pre><code># docker-compose.yml\nservices:\n media-api:\n volumes:\n - /media/local/inbox:/media/local/inbox:rw # MUST have :rw suffix\n</code></pre> <ol> <li>Verify mount in running container:</li> </ol> <pre><code>docker compose exec media-api mount | grep inbox\n# Should show /media/local/inbox mounted as rw (read-write)\n</code></pre> <ol> <li>Check directory permissions:</li> </ol> <pre><code># 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</code></pre> <ol> <li>Create directory if missing:</li> </ol> <pre><code># On host\nsudo mkdir -p /media/local/inbox\nsudo chmod 777 /media/local/inbox\n\n# Restart container\ndocker compose restart media-api\n</code></pre> <ol> <li>Test write access:</li> </ol> <pre><code># 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</code></pre>"},{"location":"v2/features/media/upload/#problem-invalid-file-type-error","title":"Problem: Invalid File Type Error","text":"<p>Symptoms:</p> <ul> <li>Upload rejected immediately</li> <li>Error: \"File type not supported\"</li> <li>File is valid MP4/MOV/etc</li> </ul> <p>Solutions:</p> <ol> <li>Check MIME type:</li> </ol> <pre><code>// Browser console\nconst file = document.querySelector('input[type=file]').files[0];\nconsole.log(file.type);\n// Should be video/mp4, video/quicktime, etc.\n</code></pre> <ol> <li>Verify file extension:</li> </ol> <pre><code># Rename file to ensure correct extension\nmv video.MP4 video.mp4 # Case-sensitive on Linux\n</code></pre> <ol> <li>Add MIME type to allowed list:</li> </ol> <pre><code>// 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</code></pre> <ol> <li>Bypass frontend validation (testing only):</li> </ol> <pre><code>// Temporarily comment out beforeUpload validation\nbeforeUpload={() => false}\n</code></pre> <ol> <li>Check backend extension validation:</li> </ol> <pre><code>// api/src/modules/media/routes/upload.routes.ts\nconst allowedExtensions = ['.mp4', '.mov', '.avi', '.mkv', '.webm', '.m4v', '.flv'];\n// Add more if needed\n</code></pre>"},{"location":"v2/features/media/upload/#problem-batch-upload-only-uploads-first-file","title":"Problem: Batch Upload Only Uploads First File","text":"<p>Symptoms:</p> <ul> <li>Multiple files selected</li> <li>Only first file uploads</li> <li>Others disappear from queue</li> </ul> <p>Solutions:</p> <ol> <li>Check sequential upload logic:</li> </ol> <pre><code>// 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</code></pre> <ol> <li>Verify batch endpoint:</li> </ol> <pre><code># Use /api/media/upload/batch for multiple files\n# Not multiple calls to /api/media/upload/single\n</code></pre> <ol> <li>Check Fastify file limit:</li> </ol> <pre><code>// api/src/media-server.ts\napp.register(multipart, {\n limits: {\n files: 10, // Max 10 files per request\n },\n});\n</code></pre> <ol> <li>Frontend: prevent early unmount:</li> </ol> <pre><code>// Don't close modal while uploading\n<Modal\n closable={!uploading}\n maskClosable={!uploading}\n ...\n/>\n</code></pre>"},{"location":"v2/features/media/upload/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/features/media/upload/#upload-speed","title":"Upload Speed","text":"<p>Factors:</p> <ul> <li>Network bandwidth \u2014 100 Mbps = ~12 MB/s theoretical max</li> <li>Disk write speed \u2014 SSD: 500+ MB/s, HDD: 100-150 MB/s</li> <li>Nginx buffering \u2014 Can slow large uploads if enabled</li> <li>Docker overlay network \u2014 ~10% overhead vs host networking</li> </ul> <p>Typical Speeds:</p> File Size Upload Time (100 Mbps) Upload Time (1 Gbps) 100 MB ~10 seconds ~1 second 1 GB ~1.5 minutes ~10 seconds 5 GB ~7 minutes ~50 seconds 10 GB ~14 minutes ~1.5 minutes <p>Optimization:</p> <ol> <li>Disable nginx buffering:</li> </ol> <pre><code># 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</code></pre> <ol> <li>Use faster disk:</li> </ol> <p>Mount <code>/media/local/inbox</code> on SSD instead of HDD.</p> <ol> <li>Increase network MTU:</li> </ol> <pre><code># Increase Docker network MTU\ndocker network create --opt com.docker.network.driver.mtu=9000 changemaker-lite\n</code></pre>"},{"location":"v2/features/media/upload/#ffprobe-extraction-time","title":"FFprobe Extraction Time","text":"<p>Benchmarks:</p> Video Size Resolution Extraction Time 50 MB 720p ~50-100ms 200 MB 1080p ~100-200ms 1 GB 1080p ~200-400ms 5 GB 4K ~500ms-1s <p>Optimization:</p> <p>FFprobe only reads video metadata (not entire file), so extraction time scales sub-linearly with file size.</p> <p>For very large files (10GB+), consider deferring extraction to job queue:</p> <pre><code>// 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</code></pre>"},{"location":"v2/features/media/upload/#streaming-vs-buffering","title":"Streaming vs Buffering","text":"<p>Memory Usage Comparison:</p> Upload Method Memory Usage (10GB file) Streaming (current) ~10 MB Buffering (alternative) ~10 GB <p>Why Streaming:</p> <ul> <li>Constant memory \u2014 Uses fixed ~10 MB buffer regardless of file size</li> <li>Server stability \u2014 10 concurrent uploads = ~100 MB RAM vs 100 GB if buffered</li> <li>No 32-bit limit \u2014 Buffering fails on Node.js for files > 2GB on 32-bit systems</li> </ul> <p>Tradeoff:</p> <p>Streaming writes directly to disk, so failed uploads leave partial files in <code>/inbox</code>. Cleanup script required:</p> <pre><code># Cron job to clean incomplete uploads (files with 0 size)\nfind /media/local/inbox -type f -size 0 -mtime +1 -delete\n</code></pre>"},{"location":"v2/features/media/upload/#security-considerations","title":"Security Considerations","text":""},{"location":"v2/features/media/upload/#admin-only-access","title":"Admin-Only Access","text":"<p>All upload endpoints require <code>SUPER_ADMIN</code> role:</p> <pre><code>// 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</code></pre> <p>Regular users, volunteers, and public cannot upload videos.</p>"},{"location":"v2/features/media/upload/#file-extension-validation","title":"File Extension Validation","text":"<p>Backend enforces strict whitelist:</p> <pre><code>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</code></pre> <p>No executable extensions allowed:</p> <ul> <li>\u274c <code>.exe</code></li> <li>\u274c <code>.sh</code></li> <li>\u274c <code>.bat</code></li> <li>\u274c <code>.php</code></li> <li>\u274c <code>.js</code> (only video extensions)</li> </ul>"},{"location":"v2/features/media/upload/#path-traversal-prevention","title":"Path Traversal Prevention","text":"<p>UUID filenames prevent directory traversal:</p> <pre><code>// 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</code></pre> <p>Original filename preserved in database:</p> <pre><code>originalFilename: data.filename, // Stored for reference, not used for filepath\n</code></pre>"},{"location":"v2/features/media/upload/#virus-scanning-future","title":"Virus Scanning (Future)","text":"<p>Recommended Integration:</p> <pre><code>// 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</code></pre>"},{"location":"v2/features/media/upload/#rate-limiting","title":"Rate Limiting","text":"<p>Upload endpoint has stricter rate limits:</p> <pre><code>// 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</code></pre> <p>Prevents abuse (uploading hundreds of large files).</p>"},{"location":"v2/features/media/upload/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/features/media/upload/#backend-documentation","title":"Backend Documentation","text":"<ul> <li>Upload Routes: <code>backend/modules/media/upload.md</code> \u2014 Upload endpoint implementation</li> <li>FFprobe Service: <code>backend/modules/media/ffprobe.md</code> \u2014 Metadata extraction service</li> <li>Fastify Multipart: <code>backend/api/media-server.md</code> \u2014 Multipart plugin configuration</li> </ul>"},{"location":"v2/features/media/upload/#frontend-documentation","title":"Frontend Documentation","text":"<ul> <li>Upload Modal: <code>frontend/components/media/upload-modal.md</code> \u2014 Upload UI component</li> <li>Library Page: <code>frontend/pages/media/library.md</code> \u2014 Integration with library table</li> </ul>"},{"location":"v2/features/media/upload/#feature-documentation","title":"Feature Documentation","text":"<ul> <li>Video Library: <code>features/media/video-library.md</code> \u2014 Video management system overview</li> <li>Media Jobs: <code>features/media/jobs.md</code> \u2014 Background processing for uploads</li> </ul>"},{"location":"v2/features/media/upload/#deployment-documentation","title":"Deployment Documentation","text":"<ul> <li>Docker Volumes: <code>deployment/docker.md</code> \u2014 Volume mount configuration for inbox</li> <li>Nginx: <code>deployment/nginx.md</code> \u2014 Reverse proxy upload timeout settings</li> </ul>"},{"location":"v2/features/media/upload/#next-steps","title":"Next Steps","text":"<p>After mastering video upload:</p> <ol> <li>Move Videos \u2014 Learn how to move uploaded videos from <code>/inbox</code> to target directories</li> <li>Thumbnail Generation \u2014 Create thumbnails for video previews</li> <li>Encoding Jobs \u2014 Queue re-encoding jobs for web-optimized playback</li> <li>Public Sharing \u2014 Share videos in public gallery (see <code>public-gallery.md</code>)</li> </ol> <p>Hands-On Practice:</p> <pre><code># 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</code></pre> <p>Last Updated: 2026-02-13 Version: V2.0 Maintainer: Changemaker Lite Team</p>"},{"location":"v2/features/media/video-library/","title":"Video Library Management","text":""},{"location":"v2/features/media/video-library/#overview","title":"Overview","text":"<p>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.</p> <p>Key Features:</p> <ul> <li>Dual API Architecture \u2014 Fastify media API (port 4100) separate from Express API (port 4000)</li> <li>Drizzle ORM \u2014 Media tables use Drizzle ORM instead of Prisma for schema flexibility</li> <li>9 Directory Types \u2014 Organized library structure (studios, gifs, private, inbox, curated, playback, compilations, videos, highlights)</li> <li>FFprobe Integration \u2014 Automatic metadata extraction (duration, dimensions, orientation, quality, audio detection)</li> <li>Video CRUD \u2014 Full create, read, update, delete operations (admin-only)</li> <li>Directory Scanning \u2014 Bulk import videos from filesystem with automatic record creation</li> <li>Validation System \u2014 Re-validate videos to refresh metadata and check file integrity</li> <li>File Hashing \u2014 Duplicate detection via SHA-256 file hashing</li> <li>Soft Delete \u2014 Videos marked invalid instead of hard deletion (preserves history)</li> <li>Thumbnail Support \u2014 Custom thumbnail paths for video previews</li> </ul> <p>Access Control:</p> <ul> <li>All video library operations require <code>SUPER_ADMIN</code> role</li> <li>Public video viewing handled separately via Shared Media system (see <code>public-gallery.md</code>)</li> </ul> <p>Technology Stack:</p> <ul> <li>Fastify 4.x \u2014 High-performance Node.js web framework</li> <li>Drizzle ORM \u2014 TypeScript-first ORM with zero-runtime overhead</li> <li>FFprobe \u2014 FFmpeg's media file analyzer for metadata extraction</li> <li>PostgreSQL 16 \u2014 Shared database with main API</li> </ul>"},{"location":"v2/features/media/video-library/#architecture","title":"Architecture","text":"<p>The Media API operates as an independent microservice while maintaining data consistency through shared database access:</p> <pre><code>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</code></pre> <p>Architecture Highlights:</p> <ol> <li>Port Separation \u2014 Media API on 4100, Main API on 4000</li> <li>ORM Independence \u2014 Drizzle for media, Prisma for everything else</li> <li>Shared Database \u2014 Both APIs access same PostgreSQL instance</li> <li>File System Access \u2014 Media API has direct volume mount to <code>/media/local/library</code></li> <li>Nginx Routing \u2014 <code>media.cmlite.org</code> routes to port 4100</li> </ol> <p>Why Dual API?</p> <p>The media system was added after V2 launch as a self-contained enhancement. Keeping it as a separate Fastify microservice:</p> <ul> <li>Avoids disrupting the stable Express API</li> <li>Allows independent scaling and deployment</li> <li>Provides testing ground for Drizzle ORM migration</li> <li>Isolates video processing workloads from core application logic</li> </ul>"},{"location":"v2/features/media/video-library/#database-model-drizzle","title":"Database Model (Drizzle)","text":""},{"location":"v2/features/media/video-library/#videos-table-schema","title":"Videos Table Schema","text":"<pre><code>// 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</code></pre>"},{"location":"v2/features/media/video-library/#directory-types-enum","title":"Directory Types Enum","text":"Directory Type Purpose Public Eligible <code>studios</code> Studio-organized content \u2705 <code>gifs</code> Short looping videos \u2705 <code>private</code> Private/unreleased content \u274c <code>inbox</code> Upload staging area \u274c <code>curated</code> Hand-picked highlights \u2705 <code>playback</code> Playback-optimized encodes \u2705 <code>compilations</code> Multi-video compilations \u2705 <code>videos</code> General video library \u2705 <code>highlights</code> Auto-generated highlights \u2705"},{"location":"v2/features/media/video-library/#quality-classifications","title":"Quality Classifications","text":"Quality Height Range Typical Resolution <code>SD</code> < 720px 480p, 576p <code>HD</code> 720px - 1079px 720p <code>FHD</code> 1080px - 2159px 1080p <code>UHD</code> \u2265 2160px 4K, 8K"},{"location":"v2/features/media/video-library/#orientation-detection","title":"Orientation Detection","text":"<pre><code>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</code></pre>"},{"location":"v2/features/media/video-library/#api-endpoints","title":"API Endpoints","text":"<p>All endpoints require authentication with <code>SUPER_ADMIN</code> role unless marked as public.</p>"},{"location":"v2/features/media/video-library/#list-videos","title":"List Videos","text":"<pre><code>GET /api/media/videos\n</code></pre> <p>Query Parameters:</p> Parameter Type Default Description <code>page</code> number 1 Page number for pagination <code>limit</code> number 20 Results per page (max 100) <code>directoryType</code> string - Filter by directory (studios, gifs, etc.) <code>orientation</code> string - Filter by orientation (portrait, landscape, square) <code>producer</code> string - Filter by producer (partial match) <code>creator</code> string - Filter by creator (partial match) <code>quality</code> string - Filter by quality (SD, HD, FHD, UHD) <code>hasAudio</code> boolean - Filter by audio presence <code>isValid</code> boolean true Filter by validation status <code>search</code> string - Search in title, producer, creator <p>Response:</p> <pre><code>{\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</code></pre>"},{"location":"v2/features/media/video-library/#get-video-details","title":"Get Video Details","text":"<pre><code>GET /api/media/videos/:id\n</code></pre> <p>Response:</p> <pre><code>{\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</code></pre>"},{"location":"v2/features/media/video-library/#create-video-record","title":"Create Video Record","text":"<pre><code>POST /api/media/videos\n</code></pre> <p>Request Body:</p> <pre><code>{\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</code></pre> <p>Notes:</p> <ul> <li>File must already exist at specified path on filesystem</li> <li>FFprobe metadata extraction runs automatically after creation</li> <li>Use <code>/api/media/upload/single</code> for file upload + record creation</li> </ul> <p>Response:</p> <pre><code>{\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</code></pre>"},{"location":"v2/features/media/video-library/#update-video-metadata","title":"Update Video Metadata","text":"<pre><code>PUT /api/media/videos/:id\n</code></pre> <p>Request Body:</p> <pre><code>{\n \"producer\": \"Updated Studio\",\n \"creator\": \"New Director\",\n \"title\": \"Updated Title\",\n \"tags\": [\"updated\", \"tags\"]\n}\n</code></pre> <p>Updatable Fields:</p> <ul> <li><code>producer</code> \u2014 Video producer/studio</li> <li><code>creator</code> \u2014 Director/creator name</li> <li><code>title</code> \u2014 Display title</li> <li><code>tags</code> \u2014 Array of tag strings</li> <li><code>thumbnailPath</code> \u2014 Custom thumbnail path</li> </ul> <p>Response:</p> <pre><code>{\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</code></pre>"},{"location":"v2/features/media/video-library/#delete-video","title":"Delete Video","text":"<pre><code>DELETE /api/media/videos/:id\n</code></pre> <p>Behavior:</p> <ul> <li>Soft Delete \u2014 Sets <code>isValid = false</code> instead of removing record</li> <li>File remains on filesystem (manual cleanup required)</li> <li>Video no longer appears in default listings</li> <li>Can be restored by setting <code>isValid = true</code> via database</li> </ul> <p>Response:</p> <pre><code>{\n \"success\": true,\n \"message\": \"Video marked as invalid\"\n}\n</code></pre>"},{"location":"v2/features/media/video-library/#scan-directory","title":"Scan Directory","text":"<pre><code>POST /api/media/videos/scan\n</code></pre> <p>Request Body:</p> <pre><code>{\n \"directoryType\": \"videos\",\n \"skipExisting\": true\n}\n</code></pre> <p>Parameters:</p> Field Type Required Description <code>directoryType</code> string \u2705 Directory to scan (videos, studios, etc.) <code>skipExisting</code> boolean - Skip files already in database (default: true) <p>Process:</p> <ol> <li>Reads filesystem directory <code>/media/local/library/{directoryType}/</code></li> <li>Filters for video extensions (<code>.mp4</code>, <code>.mov</code>, <code>.avi</code>, <code>.mkv</code>, <code>.webm</code>, <code>.m4v</code>, <code>.flv</code>)</li> <li>Checks each file against database (by path)</li> <li>Creates records for new files</li> <li>Runs FFprobe metadata extraction on new records</li> </ol> <p>Response:</p> <pre><code>{\n \"scanned\": 45,\n \"created\": 12,\n \"skipped\": 33,\n \"failed\": 0,\n \"errors\": []\n}\n</code></pre>"},{"location":"v2/features/media/video-library/#validate-video","title":"Validate Video","text":"<pre><code>POST /api/media/videos/:id/validate\n</code></pre> <p>Purpose:</p> <ul> <li>Re-run FFprobe metadata extraction</li> <li>Update video properties (duration, dimensions, etc.)</li> <li>Verify file still exists and is readable</li> <li>Refresh file size and hash</li> </ul> <p>Response:</p> <pre><code>{\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</code></pre>"},{"location":"v2/features/media/video-library/#configuration","title":"Configuration","text":""},{"location":"v2/features/media/video-library/#environment-variables","title":"Environment Variables","text":"<pre><code># 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</code></pre>"},{"location":"v2/features/media/video-library/#docker-volume-mounts","title":"Docker Volume Mounts","text":"<pre><code># 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</code></pre> <p>Important: Inbox requires <code>:rw</code> (read-write) for uploads. Library can be <code>:ro</code> (read-only) for security.</p>"},{"location":"v2/features/media/video-library/#site-settings","title":"Site Settings","text":"<p>The media system respects the global <code>ENABLE_MEDIA_FEATURES</code> flag in Site Settings:</p> <pre><code>SELECT * FROM settings WHERE key = 'ENABLE_MEDIA_FEATURES';\n</code></pre> <p>When disabled:</p> <ul> <li>Media API still runs but returns 503 Service Unavailable</li> <li>Admin GUI hides Media menu items</li> <li>Public gallery shows maintenance message</li> </ul>"},{"location":"v2/features/media/video-library/#admin-workflow","title":"Admin Workflow","text":""},{"location":"v2/features/media/video-library/#viewing-the-video-library","title":"Viewing the Video Library","text":"<ol> <li>Navigate to Media \u2192 Library in admin sidebar</li> <li>Table displays all videos with:</li> <li>Thumbnail preview</li> <li>Title, producer, creator</li> <li>Duration, quality, orientation</li> <li>Directory type</li> <li>File size</li> <li>Created date</li> <li>Use filters at top:</li> <li>Directory Type dropdown</li> <li>Orientation radio buttons (All / Portrait / Landscape / Square)</li> <li>Quality checkboxes (SD, HD, FHD, UHD)</li> <li>Search input (searches title, producer, creator)</li> </ol>"},{"location":"v2/features/media/video-library/#scanning-a-directory","title":"Scanning a Directory","text":"<p>When to Use:</p> <ul> <li>After manually copying videos to library directory</li> <li>After video processing jobs complete</li> <li>When videos exist on filesystem but not in database</li> </ul> <p>Steps:</p> <ol> <li>Click \"Scan Directory\" button in Library page toolbar</li> <li>Select directory type from dropdown</li> <li>Toggle \"Skip Existing\" (recommended for large libraries)</li> <li>Click \"Start Scan\"</li> <li>Progress modal shows:</li> <li>Files scanned</li> <li>New records created</li> <li>Skipped (already in DB)</li> <li>Failed (with error messages)</li> <li>Click \"Close\" when complete</li> <li>Table refreshes with new videos</li> </ol> <p>Example Output:</p> <pre><code>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</code></pre>"},{"location":"v2/features/media/video-library/#editing-video-metadata","title":"Editing Video Metadata","text":"<ol> <li>Click pencil icon in video row</li> <li>Edit modal opens with fields:</li> <li>Producer \u2014 Studio or production company</li> <li>Creator \u2014 Director or primary creator</li> <li>Title \u2014 Display title</li> <li>Tags \u2014 Comma-separated tags (auto-suggests existing tags)</li> <li>Click \"Save\" to update</li> <li>Metadata changes immediately visible in table</li> </ol> <p>Bulk Editing:</p> <ol> <li>Select multiple videos using checkboxes</li> <li>Click \"Bulk Edit\" button</li> <li>Set common fields (producer, tags, etc.)</li> <li>Click \"Apply to Selected\"</li> </ol>"},{"location":"v2/features/media/video-library/#validating-videos","title":"Validating Videos","text":"<p>Purpose: Refresh metadata and verify file integrity</p> <p>Steps:</p> <ol> <li>Click \"Validate\" button in video row (or Actions dropdown)</li> <li>FFprobe re-analyzes video file</li> <li>Database updates with fresh metadata:</li> <li>Duration (may have changed if file was re-encoded)</li> <li>Dimensions</li> <li>Audio detection</li> <li>File size and hash</li> <li><code>lastValidated</code> timestamp updates</li> <li>If file missing or corrupt, <code>isValid</code> set to <code>false</code></li> </ol> <p>Bulk Validation:</p> <ol> <li>Select multiple videos</li> <li>Click \"Validate Selected\"</li> <li>Progress modal shows validation results</li> <li>Failed validations highlighted in red</li> </ol>"},{"location":"v2/features/media/video-library/#deleting-videos","title":"Deleting Videos","text":"<p>Soft Delete (Default):</p> <ol> <li>Click trash icon in video row</li> <li>Confirm deletion dialog</li> <li>Video marked <code>isValid = false</code></li> <li>Video disappears from default view</li> <li>File remains on filesystem</li> <li>Record preserved in database</li> </ol> <p>Viewing Deleted Videos:</p> <ol> <li>Toggle \"Show Invalid\" filter</li> <li>Deleted videos appear with strikethrough</li> <li>Can restore by clicking \"Restore\" button</li> </ol> <p>Hard Delete (Database Only):</p> <ol> <li>Filter for invalid videos</li> <li>Select video(s)</li> <li>Click \"Permanently Delete\"</li> <li>Removes database record</li> <li>File still on filesystem (manual cleanup required)</li> </ol> <p>File System Cleanup:</p> <p>Deleted video files must be manually removed from filesystem:</p> <pre><code># 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</code></pre>"},{"location":"v2/features/media/video-library/#directory-structure","title":"Directory Structure","text":"<pre><code>/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</code></pre> <p>Directory Guidelines:</p> <ul> <li>studios/ \u2014 Organize by producer/studio name (subfolder structure allowed)</li> <li>gifs/ \u2014 Short videos under 15 seconds, suitable for looping</li> <li>private/ \u2014 Never shared publicly, admin-only access</li> <li>inbox/ \u2014 Temporary upload location, files moved after processing</li> <li>curated/ \u2014 High-quality selections for public gallery homepage</li> <li>playback/ \u2014 Web-optimized encodes (H.264, web-friendly profiles)</li> <li>compilations/ \u2014 Merged videos created by compilation jobs</li> <li>videos/ \u2014 Main library, all-purpose storage</li> <li>highlights/ \u2014 AI-generated or manually created highlight reels</li> </ul>"},{"location":"v2/features/media/video-library/#code-examples","title":"Code Examples","text":""},{"location":"v2/features/media/video-library/#list-videos-with-filters-fastify-route","title":"List Videos with Filters (Fastify Route)","text":"<pre><code>// 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</code></pre>"},{"location":"v2/features/media/video-library/#scan-directory-for-videos","title":"Scan Directory for Videos","text":"<pre><code>// 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</code></pre>"},{"location":"v2/features/media/video-library/#validate-video-metadata","title":"Validate Video Metadata","text":"<pre><code>// 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</code></pre>"},{"location":"v2/features/media/video-library/#frontend-library-page-table","title":"Frontend: Library Page Table","text":"<pre><code>// 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</code></pre>"},{"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":"<p>Symptoms:</p> <ul> <li>Admin GUI shows \"Cannot connect to media API\"</li> <li>Browser console shows CORS errors or network failures</li> <li>Public gallery doesn't load</li> </ul> <p>Solutions:</p> <ol> <li>Check Fastify server running:</li> </ol> <pre><code>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</code></pre> <ol> <li>Verify port 4100 not in use:</li> </ol> <pre><code>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</code></pre> <ol> <li>Check nginx proxy configuration:</li> </ol> <pre><code># 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</code></pre> <ol> <li>Test direct API access:</li> </ol> <pre><code># 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</code></pre> <ol> <li>Check Docker networking:</li> </ol> <pre><code>docker network inspect changemaker-lite\n# Verify media-api container connected\n</code></pre>"},{"location":"v2/features/media/video-library/#problem-scan-finds-no-videos","title":"Problem: Scan Finds No Videos","text":"<p>Symptoms:</p> <ul> <li>Scan completes with \"Created 0 new records\"</li> <li>Directory known to contain video files</li> <li>Scan reports 0 files scanned</li> </ul> <p>Solutions:</p> <ol> <li>Verify MEDIA_LIBRARY_PATH correct:</li> </ol> <pre><code># 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</code></pre> <ol> <li>Check directory exists:</li> </ol> <pre><code># 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</code></pre> <ol> <li>Verify Docker volume mounted:</li> </ol> <pre><code># docker-compose.yml\nservices:\n media-api:\n volumes:\n - /media/local/library:/media/local/library:ro # Check path correct\n</code></pre> <pre><code># Inspect volume mounts\ndocker compose config | grep -A 5 media-api\n</code></pre> <ol> <li>Check file extensions supported:</li> </ol> <p>Only these extensions scanned:</p> <ul> <li><code>.mp4</code></li> <li><code>.mov</code></li> <li><code>.avi</code></li> <li><code>.mkv</code></li> <li><code>.webm</code></li> <li><code>.m4v</code></li> <li><code>.flv</code></li> </ul> <p>Rename files if using other extensions:</p> <pre><code># Rename .MP4 to .mp4 (case-sensitive)\ndocker compose exec media-api sh -c 'cd /media/local/library/videos && rename \"s/.MP4$/.mp4/\" *.MP4'\n</code></pre> <ol> <li>Check file permissions:</li> </ol> <pre><code># 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</code></pre>"},{"location":"v2/features/media/video-library/#problem-ffprobe-validation-fails","title":"Problem: FFprobe Validation Fails","text":"<p>Symptoms:</p> <ul> <li>Validation returns error \"FFprobe command failed\"</li> <li>Videos marked <code>isValid = false</code></li> <li>Timeout errors after 30 seconds</li> </ul> <p>Solutions:</p> <ol> <li>Check FFmpeg installed in container:</li> </ol> <pre><code># 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</code></pre> <ol> <li>Install FFmpeg if missing:</li> </ol> <pre><code># 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</code></pre> <pre><code># Rebuild container\ndocker compose build media-api\ndocker compose up -d media-api\n</code></pre> <ol> <li>Test FFprobe directly on video:</li> </ol> <pre><code># 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</code></pre> <ol> <li>Check timeout not exceeded:</li> </ol> <p>Default timeout: 30 seconds</p> <pre><code># For very large files (>5GB), increase timeout\n# api/src/modules/media/services/ffprobe.service.ts\nconst FFPROBE_TIMEOUT = 60000; // 60 seconds\n</code></pre> <ol> <li>Verify video file not corrupt:</li> </ol> <pre><code># 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</code></pre> <ol> <li>Check for special characters in filename:</li> </ol> <pre><code># Rename files with spaces or special chars\ndocker compose exec media-api sh -c 'cd /media/local/library/videos && rename \"s/ /_/g\" *.mp4'\n</code></pre>"},{"location":"v2/features/media/video-library/#problem-drizzle-schema-changes-not-applied","title":"Problem: Drizzle Schema Changes Not Applied","text":"<p>Symptoms:</p> <ul> <li>Code references new column but database doesn't have it</li> <li>Error: \"column does not exist\"</li> <li>Schema changes made but not reflected</li> </ul> <p>Solutions:</p> <ol> <li>Push schema changes:</li> </ol> <pre><code># Drizzle uses push (not migrations)\ncd api\nnpx drizzle-kit push\n\n# Confirm changes\n</code></pre> <ol> <li>Verify connection:</li> </ol> <pre><code># 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</code></pre> <ol> <li>Compare with Prisma migrations:</li> </ol> <p>Media tables exist in same database as Prisma tables. If conflict:</p> <pre><code># Check both schemas\nnpx prisma db pull # Prisma introspection\nnpx drizzle-kit introspect # Drizzle introspection\n\n# Resolve conflicts manually\n</code></pre>"},{"location":"v2/features/media/video-library/#problem-large-library-performance","title":"Problem: Large Library Performance","text":"<p>Symptoms:</p> <ul> <li>Library page loads slowly (5+ seconds)</li> <li>Pagination sluggish</li> <li>Scan operations timeout</li> </ul> <p>Solutions:</p> <ol> <li>Add database indexes:</li> </ol> <pre><code>-- 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</code></pre> <ol> <li>Reduce page size:</li> </ol> <pre><code>// admin/src/pages/media/LibraryPage.tsx\nconst [pagination, setPagination] = useState({ page: 1, limit: 10, total: 0 });\n// Reduced from 20 to 10\n</code></pre> <ol> <li>Enable query caching:</li> </ol> <pre><code>// 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</code></pre> <ol> <li>Use virtual scrolling:</li> </ol> <pre><code>// Replace Ant Design Table with react-window for large datasets\nimport { FixedSizeList } from 'react-window';\n</code></pre>"},{"location":"v2/features/media/video-library/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/features/media/video-library/#directory-scans","title":"Directory Scans","text":"<p>Scaling Factors:</p> <ul> <li>100 files: ~2 seconds</li> <li>1,000 files: ~15 seconds</li> <li>10,000 files: ~2.5 minutes</li> </ul> <p>Optimization Strategies:</p> <ol> <li>Incremental Scans \u2014 Use <code>skipExisting: true</code> to only process new files</li> <li>Parallel Processing \u2014 Scan multiple directories simultaneously</li> <li>Background Jobs \u2014 Queue scans as async jobs instead of synchronous requests</li> <li>Caching \u2014 Cache directory listings in Redis</li> </ol>"},{"location":"v2/features/media/video-library/#ffprobe-extraction","title":"FFprobe Extraction","text":"<p>Timing:</p> <ul> <li>Small video (<100MB): ~50-100ms</li> <li>Medium video (500MB): ~150-250ms</li> <li>Large video (2GB+): ~500ms-1s</li> </ul> <p>Batch Processing:</p> <p>For 100 videos: ~10-20 seconds total</p> <p>Optimization:</p> <pre><code>// 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</code></pre>"},{"location":"v2/features/media/video-library/#database-queries","title":"Database Queries","text":"<p>Query Performance:</p> <ul> <li>List 20 videos (no filters): ~5-10ms</li> <li>List 20 videos (with filters): ~10-20ms</li> <li>Full-text search: ~20-50ms</li> <li>Count total videos: ~5ms (with index)</li> </ul> <p>Optimization:</p> <ol> <li>Always use pagination \u2014 Never fetch all records</li> <li>Index heavily filtered columns \u2014 directoryType, orientation, quality, isValid</li> <li>Use SELECT only needed columns \u2014 Avoid <code>SELECT *</code> for large tables</li> <li>Cache counts \u2014 Total video count changes infrequently, cache in Redis</li> </ol>"},{"location":"v2/features/media/video-library/#thumbnail-generation","title":"Thumbnail Generation","text":"<p>Deferred Loading:</p> <p>Don't generate thumbnails during scan. Instead:</p> <ol> <li>Create video record without thumbnail</li> <li>Queue thumbnail generation job</li> <li>Worker processes job asynchronously</li> <li>Update record with <code>thumbnailPath</code></li> </ol> <p>Lazy Loading:</p> <p>Frontend requests thumbnails only when visible (IntersectionObserver).</p>"},{"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":"<p>The media system was introduced as a Phase 14 enhancement after V2 core functionality stabilized. A separate Fastify microservice was chosen to:</p> <ol> <li>Avoid Disrupting Stable Express API \u2014 V2 Express API battle-tested with 30+ models, introducing media directly risked regressions</li> <li>Test Drizzle ORM Migration \u2014 Fastify+Drizzle serves as proof-of-concept for potential future Prisma\u2192Drizzle migration</li> <li>Isolate Video Processing \u2014 CPU/GPU-intensive FFprobe, encoding jobs isolated from main API request handling</li> <li>Independent Scaling \u2014 Media API can be horizontally scaled separately based on video processing load</li> <li>Technology Experimentation \u2014 Fastify's performance benefits evaluated for potential broader adoption</li> </ol>"},{"location":"v2/features/media/video-library/#database-sharing-strategy","title":"Database Sharing Strategy","text":"<p>Same PostgreSQL, Different ORMs:</p> <pre><code>\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</code></pre> <p>Benefits:</p> <ul> <li>Single Source of Truth \u2014 All data in one database</li> <li>Cross-API Queries \u2014 Main API can query media tables via Prisma raw queries</li> <li>Unified Backups \u2014 One PostgreSQL dump includes both APIs</li> <li>Shared Connections \u2014 Connection pooling optimizations benefit both</li> </ul> <p>Challenges:</p> <ul> <li>Schema Coordination \u2014 Must manually sync schema changes between Prisma migrations and Drizzle pushes</li> <li>Type Conflicts \u2014 Same table, different type definitions (Prisma vs Drizzle types)</li> <li>Migration Complexity \u2014 Prisma generates migrations, Drizzle uses push (no migration files)</li> </ul>"},{"location":"v2/features/media/video-library/#migration-strategy-roadmap","title":"Migration Strategy Roadmap","text":"<p>Short Term (Current):</p> <ul> <li>Keep dual API architecture</li> <li>Synchronize schemas manually</li> <li>Document shared tables in both ORMs</li> </ul> <p>Medium Term (6-12 months):</p> <ul> <li>Evaluate Fastify+Drizzle performance vs Express+Prisma</li> <li>If Fastify superior, migrate select Express routes to Fastify</li> <li>If no significant benefit, consolidate media into Express+Prisma</li> </ul> <p>Long Term (12+ months):</p> <ul> <li>Unified API (either all Express or all Fastify)</li> <li>Single ORM (either all Prisma or all Drizzle)</li> <li>Deprecate less performant stack</li> </ul> <p>Migration Effort Estimate:</p> <ul> <li>Media to Express+Prisma: 3-5 days (convert Drizzle queries to Prisma, merge Fastify routes into Express)</li> <li>All to Fastify+Drizzle: 2-3 weeks (convert 30+ Prisma models to Drizzle, rewrite Express routes for Fastify)</li> </ul>"},{"location":"v2/features/media/video-library/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/features/media/video-library/#backend-documentation","title":"Backend Documentation","text":"<ul> <li>API Server: <code>backend/api/media-server.md</code> \u2014 Fastify server setup, middleware, error handling</li> <li>Videos Module: <code>backend/modules/media/videos.md</code> \u2014 Video routes, service layer, business logic</li> <li>FFprobe Service: <code>backend/modules/media/ffprobe.md</code> \u2014 Metadata extraction implementation</li> <li>Jobs System: <code>backend/modules/media/jobs.md</code> \u2014 Job queue architecture, worker processes</li> </ul>"},{"location":"v2/features/media/video-library/#frontend-documentation","title":"Frontend Documentation","text":"<ul> <li>Library Page: <code>frontend/pages/media/library.md</code> \u2014 Video library management UI</li> <li>Shared Media Page: <code>frontend/pages/media/shared.md</code> \u2014 Public gallery admin UI</li> <li>Media Components: <code>frontend/components/media.md</code> \u2014 Reusable video components</li> </ul>"},{"location":"v2/features/media/video-library/#database-documentation","title":"Database Documentation","text":"<ul> <li>Media Models: <code>database/models/media.md</code> \u2014 Drizzle schema definitions for videos, compilations, jobs</li> <li>Drizzle Setup: <code>database/drizzle.md</code> \u2014 Drizzle ORM configuration, connection management</li> </ul>"},{"location":"v2/features/media/video-library/#feature-documentation","title":"Feature Documentation","text":"<ul> <li>Video Upload: <code>features/media/upload.md</code> \u2014 Upload system workflow, FFprobe integration</li> <li>Media Jobs: <code>features/media/jobs.md</code> \u2014 Job queue system, processing pipeline</li> <li>Public Gallery: <code>features/media/public-gallery.md</code> \u2014 Public video sharing system</li> </ul>"},{"location":"v2/features/media/video-library/#integration-documentation","title":"Integration Documentation","text":"<ul> <li>Dual API Architecture: <code>architecture/dual-api.md</code> \u2014 Express+Prisma vs Fastify+Drizzle comparison</li> <li>Nginx Routing: <code>deployment/nginx.md</code> \u2014 Reverse proxy configuration for media.cmlite.org</li> <li>Docker Setup: <code>deployment/docker.md</code> \u2014 Media API container, volume mounts, healthchecks</li> </ul>"},{"location":"v2/features/media/video-library/#next-steps","title":"Next Steps","text":"<p>After mastering video library management:</p> <ol> <li>Upload System \u2014 Read <code>features/media/upload.md</code> to understand video upload workflow</li> <li>Jobs Queue \u2014 Review <code>features/media/jobs.md</code> for video processing automation</li> <li>Public Gallery \u2014 Explore <code>features/media/public-gallery.md</code> for sharing videos publicly</li> <li>Custom Integrations \u2014 Use Media API endpoints to build custom video features</li> </ol> <p>For hands-on practice, try:</p> <pre><code># 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</code></pre> <p>Last Updated: 2026-02-13 Version: V2.0 Maintainer: Changemaker Lite Team</p>"},{"location":"v2/features/newsletter/","title":"Newsletter Integration (Listmonk)","text":"<p>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.</p>"},{"location":"v2/features/newsletter/#overview","title":"Overview","text":"<p>The Listmonk integration provides:</p> <ul> <li>Opt-in Sync - Controlled by <code>LISTMONK_SYNC_ENABLED</code> flag</li> <li>Automatic Subscriber Creation - Campaign participants \u2192 subscribers</li> <li>List Management - Campaigns \u2192 lists, Locations \u2192 lists</li> <li>User Role Sync - User roles \u2192 list assignment</li> <li>Bi-directional Updates - Keep data synchronized</li> <li>Admin Interface - Manual sync controls and monitoring</li> </ul>"},{"location":"v2/features/newsletter/#features","title":"Features","text":""},{"location":"v2/features/newsletter/#subscriber-sync","title":"Subscriber Sync","text":"<p>Automatically sync users to Listmonk:</p> <ul> <li>Campaign Participants - Email senders become subscribers</li> <li>Shift Signups - Volunteers added to lists</li> <li>Response Submitters - Response wall participants</li> <li>Manual Users - User role-based list assignment</li> </ul>"},{"location":"v2/features/newsletter/#list-management","title":"List Management","text":"<p>Auto-create and manage lists:</p> <ul> <li>Campaign Lists - One list per campaign</li> <li>Location Lists - One list per geographic area</li> <li>Role Lists - Lists for each user role</li> <li>Custom Lists - Admin-defined lists</li> </ul>"},{"location":"v2/features/newsletter/#sync-triggers","title":"Sync Triggers","text":"<p>Automatic sync on:</p> <ul> <li>Campaign email sent</li> <li>Shift signup</li> <li>Response submission</li> <li>User registration</li> <li>Manual admin trigger</li> </ul>"},{"location":"v2/features/newsletter/#admin-controls","title":"Admin Controls","text":"<ul> <li>View sync status</li> <li>Manual sync buttons</li> <li>Test connection</li> <li>List statistics</li> <li>Reinitialize lists</li> </ul>"},{"location":"v2/features/newsletter/#architecture","title":"Architecture","text":""},{"location":"v2/features/newsletter/#backend-components","title":"Backend Components","text":"<p>Listmonk Client: - <code>api/src/services/listmonk.client.ts</code> - Typed HTTP client (native fetch) - Basic auth integration - Full REST API coverage</p> <p>Listmonk Sync Service: - <code>api/src/services/listmonk-sync.service.ts</code> - Sync orchestration - Participant \u2192 subscriber mapping - List creation and management - Error handling and logging</p> <p>Admin Module: - <code>api/src/modules/listmonk/listmonk.routes.ts</code> - Admin endpoints - Status, stats, sync controls</p> <p>Database: - No new tables (uses existing User, Campaign, Location) - Listmonk IDs stored in Prisma models (future)</p>"},{"location":"v2/features/newsletter/#frontend-components","title":"Frontend Components","text":"<p>Admin Page: - <code>admin/src/pages/ListmonkPage.tsx</code> - Newsletter management - Connection status display - Sync controls - List statistics table</p>"},{"location":"v2/features/newsletter/#configuration","title":"Configuration","text":""},{"location":"v2/features/newsletter/#environment-variables","title":"Environment Variables","text":"<pre><code># 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</code></pre>"},{"location":"v2/features/newsletter/#docker-setup","title":"Docker Setup","text":"<p>Listmonk runs as a service in <code>docker-compose.yml</code>:</p> <pre><code>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</code></pre>"},{"location":"v2/features/newsletter/#initialization","title":"Initialization","text":"<p>Auto-create API user via <code>listmonk-init</code> container:</p> <pre><code>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</code></pre>"},{"location":"v2/features/newsletter/#sync-process","title":"Sync Process","text":""},{"location":"v2/features/newsletter/#campaign-participant-sync","title":"Campaign Participant Sync","text":"<ol> <li>Email Sent - Campaign email sent via API</li> <li>Create Subscriber - POST <code>/api/subscribers</code></li> <li>Email, name from user</li> <li>Status: <code>enabled</code></li> <li>Get/Create List - GET/POST <code>/api/lists</code></li> <li>List name: Campaign name</li> <li>Type: <code>public</code> or <code>private</code></li> <li>Subscribe to List - PUT <code>/api/subscribers/:id/lists</code></li> <li>Add subscriber to campaign list</li> </ol>"},{"location":"v2/features/newsletter/#location-sync","title":"Location Sync","text":"<ol> <li>Location Created - New location added</li> <li>Get/Create List - List name: Location name/city</li> <li>Sync Users - All users in location \u2192 list</li> </ol>"},{"location":"v2/features/newsletter/#user-role-sync","title":"User Role Sync","text":"<ol> <li>User Registration - New user account</li> <li>Get Role List - <code>SUPER_ADMIN</code>, <code>INFLUENCE_ADMIN</code>, etc.</li> <li>Subscribe User - Add to role-based list</li> </ol>"},{"location":"v2/features/newsletter/#api-integration","title":"API Integration","text":""},{"location":"v2/features/newsletter/#listmonk-client-usage","title":"Listmonk Client Usage","text":"<pre><code>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</code></pre>"},{"location":"v2/features/newsletter/#sync-service-usage","title":"Sync Service Usage","text":"<pre><code>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</code></pre>"},{"location":"v2/features/newsletter/#admin-interface","title":"Admin Interface","text":""},{"location":"v2/features/newsletter/#connection-status","title":"Connection Status","text":"<p>Display: - Connected/disconnected status - Listmonk version - API endpoint - Last sync time</p>"},{"location":"v2/features/newsletter/#sync-controls","title":"Sync Controls","text":"<p>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</p>"},{"location":"v2/features/newsletter/#list-statistics","title":"List Statistics","text":"<p>Table showing: - List name - Subscriber count - Campaign/location association - Last updated time</p>"},{"location":"v2/features/newsletter/#security","title":"Security","text":""},{"location":"v2/features/newsletter/#api-authentication","title":"API Authentication","text":"<p>Listmonk v6+ requires auth on all endpoints:</p> <pre><code>const headers = {\n 'Authorization': `Basic ${btoa(`${apiUser}:${apiToken}`)}`,\n 'Content-Type': 'application/json',\n};\n</code></pre>"},{"location":"v2/features/newsletter/#token-storage","title":"Token Storage","text":"<p>API tokens stored as plaintext in Listmonk DB: - Not bcrypt hashed - Direct upsert possible - Secure via Redis authentication</p>"},{"location":"v2/features/newsletter/#data-privacy","title":"Data Privacy","text":"<ul> <li>Opt-in sync only</li> <li>User consent required (future)</li> <li>Unsubscribe support</li> <li>Data deletion on request</li> </ul>"},{"location":"v2/features/newsletter/#error-handling","title":"Error Handling","text":""},{"location":"v2/features/newsletter/#sync-failures","title":"Sync Failures","text":"<p>Handled gracefully: - Network errors logged - Failed syncs retried - Admin notifications - Error statistics</p>"},{"location":"v2/features/newsletter/#rate-limiting","title":"Rate Limiting","text":"<p>Respect Listmonk limits: - Batch operations - Delay between requests - Queue large syncs</p>"},{"location":"v2/features/newsletter/#listmonk-features","title":"Listmonk Features","text":""},{"location":"v2/features/newsletter/#campaign-management","title":"Campaign Management","text":"<p>Listmonk provides: - Email campaign creation - Template management - Scheduling - A/B testing - Analytics</p>"},{"location":"v2/features/newsletter/#subscriber-management","title":"Subscriber Management","text":"<ul> <li>Import/export subscribers</li> <li>List segmentation</li> <li>Tags and attributes</li> <li>Bounce handling</li> <li>Unsubscribe management</li> </ul>"},{"location":"v2/features/newsletter/#analytics","title":"Analytics","text":"<ul> <li>Open rates</li> <li>Click rates</li> <li>Bounce rates</li> <li>Unsubscribe rates</li> <li>Campaign reports</li> </ul>"},{"location":"v2/features/newsletter/#api-endpoints","title":"API Endpoints","text":""},{"location":"v2/features/newsletter/#admin-endpoints","title":"Admin Endpoints","text":"<pre><code>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</code></pre>"},{"location":"v2/features/newsletter/#limitations","title":"Limitations","text":""},{"location":"v2/features/newsletter/#current-limitations","title":"Current Limitations","text":"<ul> <li>Listmonk v6+ only (auth required on all endpoints)</li> <li>No webhook support (future)</li> <li>Manual sync triggers</li> <li>No bi-directional sync (Listmonk \u2192 CM Lite)</li> </ul>"},{"location":"v2/features/newsletter/#future-enhancements","title":"Future Enhancements","text":"<ul> <li>Webhook integration</li> <li>Real-time sync</li> <li>Custom field mapping</li> <li>Advanced segmentation</li> <li>Campaign stats in CM Lite</li> </ul>"},{"location":"v2/features/newsletter/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/features/newsletter/#connection-issues","title":"Connection Issues","text":"<ol> <li>Check <code>LISTMONK_SYNC_ENABLED=true</code></li> <li>Verify <code>LISTMONK_API_URL</code> reachable</li> <li>Confirm API user created</li> <li>Test credentials with curl</li> </ol>"},{"location":"v2/features/newsletter/#sync-failures_1","title":"Sync Failures","text":"<ol> <li>Check logs for errors</li> <li>Verify Listmonk database</li> <li>Test API connection</li> <li>Reinitialize if needed</li> </ol>"},{"location":"v2/features/newsletter/#related-documentation","title":"Related Documentation","text":"<ul> <li>Listmonk Page</li> <li>Listmonk Client</li> <li>Campaign Module</li> <li>Environment Variables</li> <li>Docker Compose</li> </ul>"},{"location":"v2/features/observability/","title":"Observability & Monitoring","text":"<p>The Observability feature provides comprehensive monitoring, metrics collection, and alerting for the Changemaker Lite platform. Built on the Prometheus ecosystem with Grafana dashboards and Alertmanager integration.</p>"},{"location":"v2/features/observability/#overview","title":"Overview","text":"<p>The Observability stack consists of:</p> <ol> <li>Prometheus - Metrics collection and storage</li> <li>Grafana - Visualization dashboards</li> <li>Alertmanager - Alert routing and notifications</li> <li>Custom Metrics - 12 domain-specific <code>cm_*</code> metrics</li> <li>HTTP Metrics - Request tracking and performance</li> <li>Service Health - External service monitoring</li> </ol>"},{"location":"v2/features/observability/#features","title":"Features","text":""},{"location":"v2/features/observability/#metrics-collection","title":"Metrics Collection","text":"<p>Custom Domain Metrics (12 total):</p> <p>Counters: - <code>cm_api_uptime_seconds</code> - API uptime counter - <code>cm_canvass_visits_total</code> - Total canvass visits - <code>cm_campaign_emails_sent_total</code> - Total campaign emails sent - <code>cm_geocode_requests_total</code> - Total geocode requests</p> <p>Gauges: - <code>cm_canvass_sessions_active</code> - Active canvass sessions - <code>cm_email_queue_size</code> - Email queue depth - <code>cm_geocode_queue_size</code> - Geocode queue depth - <code>cm_external_service_health</code> - Service health (0/1)</p> <p>Histograms: - <code>cm_geocode_duration_seconds</code> - Geocoding latency - <code>http_request_duration_ms</code> - HTTP request duration</p> <p>HTTP Metrics: - Request count by method/route/status - Request duration percentiles (p50, p95, p99) - Active requests gauge - Error rate tracking</p>"},{"location":"v2/features/observability/#grafana-dashboards","title":"Grafana Dashboards","text":"<p>Three pre-configured dashboards:</p> <ol> <li>Changemaker Lite Overview - System-wide metrics</li> <li>API uptime and request rates</li> <li>Queue sizes and health</li> <li>Active sessions</li> <li> <p>Error rates</p> </li> <li> <p>Canvassing Metrics - Canvass-specific metrics</p> </li> <li>Active sessions over time</li> <li>Visits by outcome</li> <li>Session duration</li> <li> <p>Volunteer leaderboard</p> </li> <li> <p>External Services - Integration health</p> </li> <li>Redis health</li> <li>PostgreSQL health</li> <li>Listmonk status</li> <li>Geocoding providers</li> </ol>"},{"location":"v2/features/observability/#alert-rules","title":"Alert Rules","text":"<p>12 predefined alert rules:</p> <p>Critical Alerts: - API down (>5 min) - Database unreachable - Redis connection lost</p> <p>Warning Alerts: - High error rate (>5%) - Queue backup (>1000 jobs) - Slow requests (p95 >2s) - Service degradation</p> <p>Info Alerts: - New deployment - Service restart - Configuration change</p>"},{"location":"v2/features/observability/#admin-interface","title":"Admin Interface","text":"<p>Observability page (<code>/app/observability</code>) with:</p> <ul> <li>Metrics Tab - Live metrics display</li> <li>Dashboards Tab - Embedded Grafana</li> <li>Alerts Tab - Active alerts and rules</li> </ul>"},{"location":"v2/features/observability/#architecture","title":"Architecture","text":""},{"location":"v2/features/observability/#backend-components","title":"Backend Components","text":"<p>Metrics Module: - <code>api/src/utils/metrics.ts</code> - Prometheus metrics definitions - <code>api/src/modules/observability/observability.routes.ts</code> - Admin API</p> <p>Instrumentation: - Express middleware for HTTP metrics - Service-level metric updates - Queue size tracking - External service health checks</p> <p>Configuration: - <code>configs/prometheus/prometheus.yml</code> - Scrape config - <code>configs/prometheus/alerts.yml</code> - Alert rules - <code>configs/grafana/dashboards/</code> - Dashboard JSON</p>"},{"location":"v2/features/observability/#frontend-components","title":"Frontend Components","text":"<p>Admin Page: - <code>admin/src/pages/ObservabilityPage.tsx</code> - Monitoring dashboard - Three tabs: Metrics, Dashboards, Alerts - Embedded Grafana iframes - Live metric cards</p> <p>Observability Components: - <code>admin/src/components/observability/MetricsChart.tsx</code> - Chart component - <code>admin/src/components/observability/ServiceHealthCard.tsx</code> - Health display</p>"},{"location":"v2/features/observability/#docker-services","title":"Docker Services","text":"<p>Monitoring Profile:</p> <p>Services run with <code>--profile monitoring</code>:</p> <pre><code>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</code></pre>"},{"location":"v2/features/observability/#configuration","title":"Configuration","text":""},{"location":"v2/features/observability/#environment-variables","title":"Environment Variables","text":"<pre><code># 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</code></pre>"},{"location":"v2/features/observability/#prometheus-scrape-targets","title":"Prometheus Scrape Targets","text":"<pre><code>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</code></pre>"},{"location":"v2/features/observability/#alert-rules_1","title":"Alert Rules","text":"<p>Example alert rule:</p> <pre><code>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</code></pre>"},{"location":"v2/features/observability/#metrics-usage","title":"Metrics Usage","text":""},{"location":"v2/features/observability/#increment-counter","title":"Increment Counter","text":"<pre><code>import { metrics } from '../utils/metrics';\n\n// Campaign email sent\nmetrics.campaignEmailsSent.inc();\n\n// Geocode request\nmetrics.geocodeRequests.inc({ provider: 'nominatim' });\n</code></pre>"},{"location":"v2/features/observability/#set-gauge","title":"Set Gauge","text":"<pre><code>// 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</code></pre>"},{"location":"v2/features/observability/#observe-histogram","title":"Observe Histogram","text":"<pre><code>// 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</code></pre>"},{"location":"v2/features/observability/#grafana-dashboards_1","title":"Grafana Dashboards","text":""},{"location":"v2/features/observability/#dashboard-setup","title":"Dashboard Setup","text":"<p>Dashboards auto-provisioned from <code>configs/grafana/dashboards/</code>:</p> <pre><code>{\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</code></pre>"},{"location":"v2/features/observability/#accessing-dashboards","title":"Accessing Dashboards","text":"<ul> <li>Direct: http://localhost:3001 (admin/admin)</li> <li>Embedded: <code>/app/observability</code> \u2192 Dashboards tab</li> <li>Subdomain: http://grafana.cmlite.org (production)</li> </ul>"},{"location":"v2/features/observability/#alertmanager","title":"Alertmanager","text":""},{"location":"v2/features/observability/#alert-routing","title":"Alert Routing","text":"<p>Configure in <code>configs/alertmanager/alertmanager.yml</code>:</p> <pre><code>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</code></pre>"},{"location":"v2/features/observability/#notification-channels","title":"Notification Channels","text":"<p>Supported receivers:</p> <ul> <li>Webhook - Gotify, Slack, Discord</li> <li>Email - SMTP notifications</li> <li>PagerDuty - Incident management</li> <li>Opsgenie - Alert management</li> </ul>"},{"location":"v2/features/observability/#service-health-monitoring","title":"Service Health Monitoring","text":""},{"location":"v2/features/observability/#external-service-checks","title":"External Service Checks","text":"<p>Monitor services via health gauges:</p> <pre><code>// 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</code></pre>"},{"location":"v2/features/observability/#docker-healthchecks","title":"Docker Healthchecks","text":"<p>Services with healthchecks:</p> <ul> <li>API - <code>wget --spider http://localhost:4000/health</code></li> <li>Media API - <code>wget --spider http://localhost:4100/health</code></li> <li>PostgreSQL - <code>pg_isready</code></li> <li>Redis - <code>redis-cli ping</code></li> <li>Listmonk - <code>wget --spider http://localhost:9000/health</code></li> </ul>"},{"location":"v2/features/observability/#performance-monitoring","title":"Performance Monitoring","text":""},{"location":"v2/features/observability/#http-request-tracking","title":"HTTP Request Tracking","text":"<p>Automatic tracking of:</p> <ul> <li>Request count by route</li> <li>Request duration percentiles</li> <li>Status code distribution</li> <li>Error rates</li> </ul>"},{"location":"v2/features/observability/#queue-monitoring","title":"Queue Monitoring","text":"<p>Track queue depths:</p> <ul> <li>Email queue size</li> <li>Geocode queue size</li> <li>Failed job count</li> <li>Processing rate</li> </ul>"},{"location":"v2/features/observability/#resource-monitoring","title":"Resource Monitoring","text":"<p>Via cAdvisor and Node Exporter:</p> <ul> <li>CPU usage</li> <li>Memory usage</li> <li>Disk I/O</li> <li>Network traffic</li> </ul>"},{"location":"v2/features/observability/#admin-interface_1","title":"Admin Interface","text":""},{"location":"v2/features/observability/#metrics-tab","title":"Metrics Tab","text":"<p>Display cards:</p> <ul> <li>API uptime</li> <li>Request rate (req/sec)</li> <li>Error rate (%)</li> <li>Queue sizes</li> <li>Active sessions</li> <li>Service health</li> </ul>"},{"location":"v2/features/observability/#dashboards-tab","title":"Dashboards Tab","text":"<p>Embedded Grafana:</p> <ul> <li>Overview dashboard</li> <li>Canvassing metrics</li> <li>External services</li> <li>Custom queries</li> </ul>"},{"location":"v2/features/observability/#alerts-tab","title":"Alerts Tab","text":"<p>Active alerts list:</p> <ul> <li>Alert name</li> <li>Severity</li> <li>Status (firing/pending/resolved)</li> <li>Duration</li> <li>Quick actions (silence, resolve)</li> </ul>"},{"location":"v2/features/observability/#starting-monitoring-stack","title":"Starting Monitoring Stack","text":"<pre><code># 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</code></pre>"},{"location":"v2/features/observability/#api-endpoints","title":"API Endpoints","text":""},{"location":"v2/features/observability/#observability-endpoints","title":"Observability Endpoints","text":"<pre><code>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</code></pre>"},{"location":"v2/features/observability/#metrics-endpoint","title":"Metrics Endpoint","text":"<pre><code>GET /metrics # Prometheus scrape endpoint\n</code></pre>"},{"location":"v2/features/observability/#related-documentation","title":"Related Documentation","text":"<ul> <li>Observability Page</li> <li>Metrics Utilities</li> <li>Docker Compose</li> <li>Monitoring Stack</li> <li>Healthchecks</li> <li>Performance Optimization</li> </ul>"},{"location":"v2/features/pages/block-library/","title":"Block Library","text":"<p>Reusable page component system with JSON schema definitions, default values, and campaign-specific customization.</p>"},{"location":"v2/features/pages/block-library/#overview","title":"Overview","text":"<p>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.</p>"},{"location":"v2/features/pages/block-library/#key-features","title":"Key Features","text":"<ul> <li>Database-Driven: Blocks stored in PostgreSQL (PageBlock model)</li> <li>JSON Schema: Define configurable properties for each block type</li> <li>Default Values: Pre-populate blocks with campaign-specific content</li> <li>Category Organization: Group blocks (Headers, Content, Actions, etc.)</li> <li>Sort Order: Control block position in editor panel</li> <li>6 Default Blocks: Hero, Text, Features, CTA, Testimonials, Contact Form</li> <li>Custom Blocks: Create campaign-specific blocks via admin API</li> </ul>"},{"location":"v2/features/pages/block-library/#architecture","title":"Architecture","text":"<pre><code>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</code></pre> <p>Flow:</p> <ol> <li>Seed: Default blocks created in <code>api/prisma/seed.ts</code></li> <li>Fetch: Editor loads all blocks via <code>GET /api/page-blocks</code></li> <li>Register: GrapesJSEditor registers each block with BlockManager</li> <li>Render: Blocks appear in left panel (grouped by category)</li> <li>Customize: Admin creates custom blocks via API (future enhancement)</li> </ol>"},{"location":"v2/features/pages/block-library/#database-model","title":"Database Model","text":""},{"location":"v2/features/pages/block-library/#pageblock-table","title":"PageBlock Table","text":"<p>Schema:</p> <pre><code>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</code></pre> <p>Fields:</p> Field Type Description <code>id</code> String (UUID) Primary key <code>type</code> String Unique identifier (e.g., <code>\"hero\"</code>, <code>\"features\"</code>) <code>label</code> String Human-readable name shown in editor <code>category</code> String? Group blocks in collapsible sections <code>sortOrder</code> Int Order within category (lower = higher in list) <code>schema</code> JSON Property definitions (field name, type, label) <code>defaults</code> JSON Default values for each schema field <code>thumbnail</code> String? Preview image URL (not implemented) <p>Indexes:</p> <ul> <li><code>type</code> (unique)</li> <li><code>category + sortOrder</code> (composite, for sorted listing)</li> </ul>"},{"location":"v2/features/pages/block-library/#default-blocks","title":"Default Blocks","text":""},{"location":"v2/features/pages/block-library/#1-hero-section","title":"1. Hero Section","text":"<p>Type: <code>hero</code></p> <p>Category: Headers</p> <p>Schema:</p> <pre><code>{\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</code></pre> <p>Defaults:</p> <pre><code>{\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</code></pre> <p>Rendered HTML:</p> <pre><code><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</code></pre>"},{"location":"v2/features/pages/block-library/#2-text-block","title":"2. Text Block","text":"<p>Type: <code>text</code></p> <p>Category: Content</p> <p>Schema:</p> <pre><code>{\n \"heading\": { \"type\": \"string\", \"label\": \"Heading\" },\n \"body\": { \"type\": \"text\", \"label\": \"Body Text\" }\n}\n</code></pre> <p>Defaults:</p> <pre><code>{\n \"heading\": \"About Us\",\n \"body\": \"Tell your story here. Explain your mission, values, and what drives your campaign forward.\"\n}\n</code></pre> <p>Rendered HTML:</p> <pre><code><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</code></pre>"},{"location":"v2/features/pages/block-library/#3-features-grid","title":"3. Features Grid","text":"<p>Type: <code>features</code></p> <p>Category: Content</p> <p>Schema:</p> <pre><code>{\n \"features\": {\n \"type\": \"array\",\n \"label\": \"Features\",\n \"items\": {\n \"title\": \"string\",\n \"description\": \"string\",\n \"icon\": \"string\"\n }\n }\n}\n</code></pre> <p>Defaults:</p> <pre><code>{\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</code></pre> <p>Rendered HTML:</p> <pre><code><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</code></pre>"},{"location":"v2/features/pages/block-library/#4-call-to-action","title":"4. Call to Action","text":"<p>Type: <code>cta</code></p> <p>Category: Actions</p> <p>Schema:</p> <pre><code>{\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</code></pre> <p>Defaults:</p> <pre><code>{\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</code></pre> <p>Rendered HTML:</p> <pre><code><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</code></pre>"},{"location":"v2/features/pages/block-library/#5-testimonials","title":"5. Testimonials","text":"<p>Type: <code>testimonials</code></p> <p>Category: Content</p> <p>Schema:</p> <pre><code>{\n \"quotes\": {\n \"type\": \"array\",\n \"label\": \"Quotes\",\n \"items\": {\n \"text\": \"string\",\n \"author\": \"string\",\n \"role\": \"string\"\n }\n }\n}\n</code></pre> <p>Defaults:</p> <pre><code>{\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</code></pre> <p>Rendered HTML:</p> <pre><code><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</code></pre>"},{"location":"v2/features/pages/block-library/#6-contact-form","title":"6. Contact Form","text":"<p>Type: <code>contact-form</code></p> <p>Category: Actions</p> <p>Schema:</p> <pre><code>{\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</code></pre> <p>Defaults:</p> <pre><code>{\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</code></pre> <p>Rendered HTML:</p> <pre><code><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</code></pre> <p>Note: Form submission not wired (static HTML). Use grapesjs-plugin-forms for backend integration.</p>"},{"location":"v2/features/pages/block-library/#api-endpoints","title":"API Endpoints","text":""},{"location":"v2/features/pages/block-library/#admin-routes","title":"Admin Routes","text":"<p>Prefix: <code>/api/page-blocks</code></p> <p>Authentication: Requires admin role (SUPER_ADMIN, INFLUENCE_ADMIN, or MAP_ADMIN)</p>"},{"location":"v2/features/pages/block-library/#list-blocks","title":"List Blocks","text":"<pre><code>GET /api/page-blocks?category=Headers\n</code></pre> <p>Query Parameters:</p> <ul> <li><code>category</code> (string?) \u2014 Filter by category</li> </ul> <p>Response:</p> <pre><code>[\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</code></pre> <p>Sorting:</p> <ul> <li>Results ordered by <code>category ASC, sortOrder ASC</code></li> <li>Blocks in same category appear in sortOrder sequence</li> </ul>"},{"location":"v2/features/pages/block-library/#get-block","title":"Get Block","text":"<pre><code>GET /api/page-blocks/:id\n</code></pre> <p>Response: Single <code>PageBlock</code> object</p> <p>Errors:</p> <ul> <li><code>404 BLOCK_NOT_FOUND</code> \u2014 Block doesn't exist</li> </ul>"},{"location":"v2/features/pages/block-library/#create-block","title":"Create Block","text":"<pre><code>POST /api/page-blocks\nContent-Type: application/json\n\n{\n \"type\": \"campaign-stats\",\n \"label\": \"Campaign Stats\",\n \"category\": \"Campaign\",\n \"sortOrder\": 10,\n \"schema\": {\n \"volunteers\": { \"type\": \"number\", \"label\": \"Volunteers\" },\n \"emails\": { \"type\": \"number\", \"label\": \"Emails Sent\" }\n },\n \"defaults\": {\n \"volunteers\": 1250,\n \"emails\": 5400\n }\n}\n</code></pre> <p>Request Body:</p> <ul> <li><code>type</code> (string, required) \u2014 Unique type identifier (alphanumeric + hyphens)</li> <li><code>label</code> (string, required) \u2014 Display name</li> <li><code>category</code> (string?) \u2014 Group name (default: <code>null</code>)</li> <li><code>sortOrder</code> (number?, default: 0) \u2014 Position in list</li> <li><code>schema</code> (JSON, required) \u2014 Property definitions</li> <li><code>defaults</code> (JSON, required) \u2014 Default values matching schema</li> <li><code>thumbnail</code> (string?) \u2014 Preview image URL</li> </ul> <p>Response: Created <code>PageBlock</code> object (201 status)</p> <p>Errors:</p> <ul> <li><code>400 VALIDATION_ERROR</code> \u2014 Invalid schema or type collision</li> </ul>"},{"location":"v2/features/pages/block-library/#update-block","title":"Update Block","text":"<pre><code>PUT /api/page-blocks/:id\nContent-Type: application/json\n\n{\n \"label\": \"Updated Label\",\n \"defaults\": {\n \"volunteers\": 2000\n }\n}\n</code></pre> <p>Request Body: (all fields optional except constraints)</p> <ul> <li><code>type</code> (string?) \u2014 Cannot change after creation (immutable)</li> <li><code>label</code> (string?)</li> <li><code>category</code> (string?)</li> <li><code>sortOrder</code> (number?)</li> <li><code>schema</code> (JSON?)</li> <li><code>defaults</code> (JSON?)</li> </ul> <p>Response: Updated <code>PageBlock</code> object</p> <p>Errors:</p> <ul> <li><code>404 BLOCK_NOT_FOUND</code> \u2014 Block doesn't exist</li> <li><code>400 VALIDATION_ERROR</code> \u2014 Invalid schema or defaults</li> </ul>"},{"location":"v2/features/pages/block-library/#delete-block","title":"Delete Block","text":"<pre><code>DELETE /api/page-blocks/:id\n</code></pre> <p>Response: 204 No Content</p> <p>Errors:</p> <ul> <li><code>404 BLOCK_NOT_FOUND</code> \u2014 Block doesn't exist</li> </ul> <p>Side Effects:</p> <ul> <li>Pages using this block will still render (HTML is cached)</li> <li>Block removed from editor panel for new pages</li> </ul>"},{"location":"v2/features/pages/block-library/#schema-format","title":"Schema Format","text":""},{"location":"v2/features/pages/block-library/#property-types","title":"Property Types","text":"<p>Supported Types:</p> Type Description Example <code>string</code> Short text field Title, subtitle, URL <code>text</code> Multi-line text Body paragraph <code>number</code> Numeric value Volunteer count, price <code>boolean</code> True/false toggle Show/hide element <code>array</code> List of items Features, testimonials"},{"location":"v2/features/pages/block-library/#simple-property","title":"Simple Property","text":"<pre><code>{\n \"title\": {\n \"type\": \"string\",\n \"label\": \"Title\"\n }\n}\n</code></pre> <p>Rendered in GrapesJS: Text input labeled \"Title\"</p>"},{"location":"v2/features/pages/block-library/#array-property","title":"Array Property","text":"<pre><code>{\n \"features\": {\n \"type\": \"array\",\n \"label\": \"Features\",\n \"items\": {\n \"title\": \"string\",\n \"description\": \"string\",\n \"icon\": \"string\"\n }\n }\n}\n</code></pre> <p>Rendered in GrapesJS:</p> <ul> <li>Repeatable item group</li> <li>Add/remove buttons</li> <li>Each item has 3 fields (title, description, icon)</li> </ul>"},{"location":"v2/features/pages/block-library/#defaults-matching","title":"Defaults Matching","text":"<p>Schema:</p> <pre><code>{\n \"heading\": { \"type\": \"string\", \"label\": \"Heading\" },\n \"count\": { \"type\": \"number\", \"label\": \"Count\" }\n}\n</code></pre> <p>Valid Defaults:</p> <pre><code>{\n \"heading\": \"Our Impact\",\n \"count\": 42\n}\n</code></pre> <p>Invalid Defaults:</p> <pre><code>{\n \"heading\": 123, // Type mismatch (should be string)\n \"count\": \"foo\" // Type mismatch (should be number)\n}\n</code></pre>"},{"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":"<ol> <li>Open Editor: Admin \u2192 Pages \u2192 Click \"Edit\" on any page</li> <li>Locate Block: Left panel \u2192 Expand \"Headers\" category</li> <li>Drag Block: Drag \"Hero Section\" to canvas</li> <li>Configure: Click block \u2192 Right panel shows properties</li> <li>Title: <code>\"Join the Movement\"</code></li> <li>Subtitle: <code>\"Together we can make a difference.\"</code></li> <li>CTA Text: <code>\"Sign Up\"</code></li> <li>CTA URL: <code>\"/shifts\"</code></li> <li>Save: Press <code>Ctrl+S</code> \u2192 Block HTML stored in database</li> </ol>"},{"location":"v2/features/pages/block-library/#creating-custom-blocks","title":"Creating Custom Blocks","text":"<p>Note: Custom block creation UI not implemented. Use API directly.</p> <p>Example: Campaign Stats Block</p> <pre><code>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</code></pre> <p>Result:</p> <ul> <li>New block appears in left panel under \"Campaign\" category</li> <li>Dragging block inserts HTML (requires <code>generateBlockHtml</code> update)</li> </ul>"},{"location":"v2/features/pages/block-library/#updating-block-defaults","title":"Updating Block Defaults","text":"<p>Use Case: Update hero CTA text for all new pages</p> <pre><code>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</code></pre> <p>Effect:</p> <ul> <li>New pages using hero block get updated defaults</li> <li>Existing pages unchanged (HTML already rendered)</li> </ul>"},{"location":"v2/features/pages/block-library/#code-examples","title":"Code Examples","text":""},{"location":"v2/features/pages/block-library/#fetching-blocks-for-editor","title":"Fetching Blocks for Editor","text":"<pre><code>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</code></pre>"},{"location":"v2/features/pages/block-library/#creating-custom-block","title":"Creating Custom Block","text":"<pre><code>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</code></pre>"},{"location":"v2/features/pages/block-library/#extending-generateblockhtml","title":"Extending generateBlockHtml()","text":"<pre><code>// 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</code></pre>"},{"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":"<p>Symptoms:</p> <ul> <li>Created block via API</li> <li>Not visible in left panel</li> <li>Other blocks show correctly</li> </ul> <p>Causes:</p> <ol> <li>GrapesJSEditor not re-fetching blocks</li> <li><code>generateBlockHtml()</code> missing case</li> <li>Category name mismatch</li> </ol> <p>Solutions:</p> <ol> <li>Reload editor:</li> <li>Close page editor \u2192 Re-open</li> <li> <p>Blocks fetched on mount</p> </li> <li> <p>Add HTML generation case: <pre><code>case 'my-new-block':\n return `<section>My block HTML</section>`;\n</code></pre></p> </li> <li> <p>Check category: <pre><code>SELECT category FROM page_blocks WHERE type = 'my-new-block';\n-- Category should match GrapesJS panel (case-sensitive)\n</code></pre></p> </li> <li> <p>Verify API response: <pre><code>curl -H \"Authorization: Bearer $TOKEN\" http://localhost:4000/api/page-blocks\n# Should include new block in response\n</code></pre></p> </li> </ol>"},{"location":"v2/features/pages/block-library/#problem-default-values-not-applying","title":"Problem: Default Values Not Applying","text":"<p>Symptoms:</p> <ul> <li>Drag block to canvas \u2192 Fields are empty</li> <li>Expected pre-filled title/subtitle</li> </ul> <p>Causes:</p> <ol> <li>Defaults not matching schema keys</li> <li>HTML template ignores defaults</li> <li>Type mismatch (string vs number)</li> </ol> <p>Solutions:</p> <ol> <li> <p>Verify defaults match schema: <pre><code>// Schema\n{ \"title\": { \"type\": \"string\" } }\n\n// Defaults (good)\n{ \"title\": \"Welcome\" }\n\n// Defaults (bad - key mismatch)\n{ \"heading\": \"Welcome\" }\n</code></pre></p> </li> <li> <p>Check HTML template: <pre><code>// Good - uses defaults\nreturn `<h1>${defaults.title || 'Fallback'}</h1>`;\n\n// Bad - ignores defaults\nreturn `<h1>Hardcoded Title</h1>`;\n</code></pre></p> </li> <li> <p>Fix type mismatch: <pre><code>// If schema says \"number\", defaults must be number\n{ \"count\": { \"type\": \"number\" } }\n{ \"count\": 42 } // Good\n{ \"count\": \"42\" } // Bad\n</code></pre></p> </li> </ol>"},{"location":"v2/features/pages/block-library/#problem-block-html-not-rendering","title":"Problem: Block HTML Not Rendering","text":"<p>Symptoms:</p> <ul> <li>Block appears in panel</li> <li>Dragging to canvas shows nothing or error</li> </ul> <p>Causes:</p> <ol> <li><code>generateBlockHtml()</code> returns invalid HTML</li> <li>Inline styles have syntax errors</li> <li>Missing closing tags</li> </ol> <p>Solutions:</p> <ol> <li> <p>Validate HTML: <pre><code>const html = generateBlockHtml('my-block', defaults);\nconsole.log(html); // Check for malformed tags\n</code></pre></p> </li> <li> <p>Test inline styles: <pre><code><!-- Bad - missing quotes -->\n<div style=padding: 20px>\n\n<!-- Good - quoted attribute -->\n<div style=\"padding: 20px;\">\n</code></pre></p> </li> <li> <p>Use template literals carefully: <pre><code>// Ensure all ${} expressions return strings\nreturn `<div>${defaults.title || ''}</div>`;\n</code></pre></p> </li> </ol>"},{"location":"v2/features/pages/block-library/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/features/pages/block-library/#block-count-impact","title":"Block Count Impact","text":"<p>Threshold: 50+ blocks in library</p> <p>Symptoms:</p> <ul> <li>Slow editor initialization (~1s+)</li> <li>Left panel laggy on scroll</li> </ul> <p>Mitigations:</p> <ol> <li>Category filtering:</li> <li>Only fetch blocks for specific category</li> <li> <p>Lazy-load categories on expand</p> </li> <li> <p>Pagination:</p> </li> <li>Load first 20 blocks, fetch more on scroll</li> <li> <p>Not implemented in current version</p> </li> <li> <p>Caching:</p> </li> <li>Store blocks in localStorage</li> <li>Refresh only when version changes</li> </ol>"},{"location":"v2/features/pages/block-library/#schema-complexity","title":"Schema Complexity","text":"<p>Issue: Deeply nested array schemas (3+ levels) slow GrapesJS rendering</p> <p>Example:</p> <pre><code>{\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</code></pre> <p>Alternative: Flatten structure or use CODE mode</p>"},{"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":"<p>Protection: All <code>/api/page-blocks</code> endpoints require admin role</p> <pre><code>router.use(authenticate);\nrouter.use(requireRole(UserRole.SUPER_ADMIN, UserRole.INFLUENCE_ADMIN, UserRole.MAP_ADMIN));\n</code></pre> <p>Risk: Malicious admin creates XSS block with <code><script></code> tags</p> <p>Mitigation:</p> <ul> <li>Accepted risk: Admins are trusted users</li> <li>Blocks only render on admin-authored pages (not user-submitted)</li> <li>Public pages use admin-created HTML (already trusted)</li> </ul>"},{"location":"v2/features/pages/block-library/#type-validation","title":"Type Validation","text":"<p>Attack: Submit block with <code>type</code> containing SQL injection</p> <p>Protection:</p> <pre><code>// 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</code></pre> <p>Safe types: <code>hero</code>, <code>text-block</code>, <code>campaign-stats-2026</code></p> <p>Rejected: <code>'; DROP TABLE--</code>, <code><script>alert(1)</script></code></p>"},{"location":"v2/features/pages/block-library/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/features/pages/block-library/#frontend-components","title":"Frontend Components","text":"<ul> <li>GrapesJSEditor \u2014 Block registration logic</li> <li>LandingPageEditor \u2014 Fetches blocks for editor</li> </ul>"},{"location":"v2/features/pages/block-library/#backend-modules","title":"Backend Modules","text":"<ul> <li>blocks.routes \u2014 CRUD endpoints</li> <li>blocks.service \u2014 Business logic</li> <li>pages.schemas \u2014 Zod schemas</li> </ul>"},{"location":"v2/features/pages/block-library/#database","title":"Database","text":"<ul> <li>PageBlock Model \u2014 Schema + indexes</li> </ul>"},{"location":"v2/features/pages/block-library/#features","title":"Features","text":"<ul> <li>Page Builder \u2014 Landing page system</li> <li>GrapesJS Editor \u2014 Editor integration</li> </ul>"},{"location":"v2/features/pages/block-library/#seed-data","title":"Seed Data","text":"<ul> <li>api/prisma/seed.ts \u2014 Default blocks definition</li> </ul>"},{"location":"v2/features/pages/grapes-editor/","title":"GrapesJS Editor Integration","text":"<p>React wrapper component for GrapesJS WYSIWYG editor with forwardRef pattern, custom block registration, and keyboard shortcuts.</p>"},{"location":"v2/features/pages/grapes-editor/#overview","title":"Overview","text":"<p>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.</p>"},{"location":"v2/features/pages/grapes-editor/#key-features","title":"Key Features","text":"<ul> <li>forwardRef Pattern: Parent components trigger save via ref handle</li> <li>Custom Block Library: Register campaign-specific blocks from database</li> <li>Plugin Ecosystem: 10+ GrapesJS plugins pre-configured</li> <li>Keyboard Shortcuts: Ctrl+S (Cmd+S on Mac) to save</li> <li>Error Boundary: Graceful fallback on initialization failure</li> <li>Mobile Detection: Desktop-only warning for small screens</li> <li>Video Block Support: Placeholder generation for media library videos</li> </ul>"},{"location":"v2/features/pages/grapes-editor/#architecture","title":"Architecture","text":"<pre><code>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</code></pre> <p>Flow:</p> <ol> <li>Mount: LandingPageEditor creates ref, renders GrapesJSEditor</li> <li>Init: GrapesJSEditor calls <code>grapesjs.init()</code> \u2192 Loads plugins</li> <li>Blocks: Registers custom blocks from PageBlock library</li> <li>Data: Loads <code>initialData</code> (GrapesJS projectData JSON)</li> <li>Expose: <code>useImperativeHandle</code> exposes <code>triggerSave()</code> method</li> <li>Save: Parent calls <code>editorRef.current.triggerSave()</code> \u2192 Runs <code>save-page</code> command</li> <li>Callback: GrapesJS extracts HTML/CSS \u2192 Calls <code>onSave()</code> \u2192 Parent saves to API</li> </ol>"},{"location":"v2/features/pages/grapes-editor/#component-api","title":"Component API","text":""},{"location":"v2/features/pages/grapes-editor/#props","title":"Props","text":"<pre><code>interface GrapesJSEditorProps {\n initialData?: Record<string, unknown>;\n onSave: (data: { projectData: Record<string, unknown>; html: string; css: string }) => void;\n customBlocks?: PageBlock[];\n}\n</code></pre> <p>Fields:</p> <ul> <li><code>initialData</code> (optional): GrapesJS <code>projectData</code> JSON from previous save</li> <li>Contains components tree, styles, assets</li> <li>Empty object <code>{}</code> for new pages</li> <li><code>onSave</code> (required): Callback when save triggered</li> <li>Receives <code>{ projectData, html, css }</code></li> <li>Parent responsibility: Send to API</li> <li><code>customBlocks</code> (optional): Array of PageBlock records from database</li> <li>Registered as draggable blocks in left panel</li> <li>See Block Library for schema</li> </ul>"},{"location":"v2/features/pages/grapes-editor/#ref-handle","title":"Ref Handle","text":"<pre><code>interface GrapesJSEditorHandle {\n triggerSave: () => void;\n}\n</code></pre> <p>Method:</p> <ul> <li><code>triggerSave()</code>: Programmatically trigger save command</li> <li>Extracts current editor state</li> <li>Calls <code>onSave</code> callback</li> <li>Used by parent's \"Save\" button or keyboard shortcut</li> </ul>"},{"location":"v2/features/pages/grapes-editor/#usage-example","title":"Usage Example","text":"<pre><code>import { useRef } from 'react';\nimport GrapesJSEditor, { GrapesJSEditorHandle } from '@/components/GrapesJSEditor';\n\nfunction MyEditor() {\n const editorRef = useRef<GrapesJSEditorHandle>(null);\n\n const handleSave = async (data) => {\n await api.put('/pages/123', {\n blocks: data.projectData,\n htmlOutput: data.html,\n cssOutput: data.css,\n });\n };\n\n const handleManualSave = () => {\n editorRef.current?.triggerSave();\n };\n\n return (\n <div>\n <button onClick={handleManualSave}>Save</button>\n <GrapesJSEditor\n ref={editorRef}\n initialData={page.blocks}\n onSave={handleSave}\n customBlocks={blocks}\n />\n </div>\n );\n}\n</code></pre>"},{"location":"v2/features/pages/grapes-editor/#grapesjs-configuration","title":"GrapesJS Configuration","text":""},{"location":"v2/features/pages/grapes-editor/#initialization-options","title":"Initialization Options","text":"<pre><code>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</code></pre> <p>Key Settings:</p> <ul> <li><code>storageManager: false</code>: Disables auto-save to localStorage (we use API persistence)</li> <li><code>height: '100%'</code>: Fills parent container (full-screen editor)</li> <li><code>canvas.styles</code>: Injects Google Fonts into preview iframe</li> </ul>"},{"location":"v2/features/pages/grapes-editor/#plugins-ecosystem","title":"Plugins Ecosystem","text":"Plugin Purpose Features <code>grapesjs-blocks-basic</code> Basic blocks Section, text, image, video, map, link, flexGrid <code>grapesjs-preset-webpage</code> Full page presets Header, footer, hero templates <code>grapesjs-plugin-forms</code> Form components Input, textarea, select, button, checkbox, radio <code>grapesjs-navbar</code> Navigation bars Responsive navbar with dropdowns <code>grapesjs-component-countdown</code> Countdown timers Event countdown with custom styling <code>grapesjs-tabs</code> Tab panels Horizontal/vertical tab containers <code>grapesjs-typed</code> Typing animation Typewriter text effect <code>grapesjs-custom-code</code> Embed raw HTML/JS Custom code blocks (advanced users) <code>grapesjs-plugin-export</code> Export templates ZIP download of HTML/CSS/assets <code>grapesjs-style-gradient</code> Gradient editor Visual gradient picker for backgrounds <code>grapesjs-touch</code> Touch support Mobile/tablet drag-and-drop (experimental) <p>Installation:</p> <pre><code>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</code></pre>"},{"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":"<pre><code>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</code></pre>"},{"location":"v2/features/pages/grapes-editor/#block-generation-logic","title":"Block Generation Logic","text":"<pre><code>// 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</code></pre> <p>Example Block:</p> <pre><code>// 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</code></pre> <p>Generated HTML:</p> <pre><code><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</code></pre>"},{"location":"v2/features/pages/grapes-editor/#built-in-block-templates","title":"Built-In Block Templates","text":"<p>1. Hero Section</p> <pre><code>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</code></pre> <p>2. Text Block</p> <pre><code>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</code></pre> <p>3. Features Grid</p> <pre><code>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</code></pre> <p>4. Call to Action</p> <pre><code>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</code></pre> <p>5. Video Block</p> <pre><code>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</code></pre>"},{"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":"<pre><code>// 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</code></pre> <p>Why <code>onSaveRef</code>?</p> <ul> <li>Avoids stale closure over <code>onSave</code> prop</li> <li>Parent can update callback without re-initializing editor</li> <li>Pattern: <code>const onSaveRef = useRef(onSave); onSaveRef.current = onSave;</code></li> </ul>"},{"location":"v2/features/pages/grapes-editor/#keyboard-shortcut","title":"Keyboard Shortcut","text":"<pre><code>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</code></pre> <p>Shortcuts:</p> <ul> <li>Windows/Linux: <code>Ctrl+S</code></li> <li>macOS: <code>Cmd+S</code></li> </ul> <p>Behavior:</p> <ul> <li>Prevents browser's default \"Save Page As...\" dialog</li> <li>Triggers GrapesJS save command</li> <li>Calls <code>onSave</code> callback with current state</li> </ul>"},{"location":"v2/features/pages/grapes-editor/#forwardref-pattern","title":"forwardRef Pattern","text":""},{"location":"v2/features/pages/grapes-editor/#implementation","title":"Implementation","text":"<pre><code>const GrapesJSEditor = forwardRef<GrapesJSEditorHandle, GrapesJSEditorProps>(\n function GrapesJSEditor({ initialData, onSave, customBlocks }, ref) {\n const editorRef = useRef<Editor | null>(null);\n\n useImperativeHandle(ref, () => ({\n triggerSave() {\n editorRef.current?.runCommand('save-page');\n },\n }));\n\n // ... rest of component\n }\n);\n</code></pre>"},{"location":"v2/features/pages/grapes-editor/#parent-usage","title":"Parent Usage","text":"<pre><code>// 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</code></pre> <p>Why forwardRef?</p> <ul> <li>Decouples save trigger from GrapesJS internals</li> <li>Parent controls when to save (toolbar button, auto-save timer, etc.)</li> <li>Cleaner API than prop drilling <code>onManualSave</code> callback</li> </ul>"},{"location":"v2/features/pages/grapes-editor/#error-handling","title":"Error Handling","text":""},{"location":"v2/features/pages/grapes-editor/#error-boundary-state","title":"Error Boundary State","text":"<pre><code>const [error, setError] = useState<string | null>(null);\n\ntry {\n editor = grapesjs.init({ /* ... */ });\n} catch (err) {\n console.error('GrapesJS init error:', err);\n setError('Failed to initialize the page editor. Please refresh the page.');\n return;\n}\n\nif (error) {\n return (\n <div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#ff4d4f' }}>\n {error}\n </div>\n );\n}\n</code></pre> <p>Failure Modes:</p> <ol> <li>Missing plugin: GrapesJS throws error during <code>init()</code></li> <li>Browser incompatibility: Old browser doesn't support ES6 modules</li> <li>Memory exhaustion: Very large <code>initialData</code> crashes tab</li> </ol> <p>Recovery:</p> <ul> <li>Error state shows user-friendly message</li> <li>No infinite re-render (error doesn't trigger re-init)</li> <li>User can refresh page or report issue</li> </ul>"},{"location":"v2/features/pages/grapes-editor/#parent-level-fallback","title":"Parent-Level Fallback","text":"<pre><code>// 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</code></pre> <p>Cascade:</p> <ol> <li>GrapesJS init error \u2192 Internal error state</li> <li>React render error \u2192 ErrorBoundary catches</li> <li>User sees fallback \u2192 Can switch to CODE mode</li> </ol>"},{"location":"v2/features/pages/grapes-editor/#mobile-detection","title":"Mobile Detection","text":""},{"location":"v2/features/pages/grapes-editor/#desktop-only-warning","title":"Desktop-Only Warning","text":"<p>Location: <code>LandingPageEditor.tsx</code> (parent component)</p> <pre><code>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</code></pre> <p>Why desktop-only?</p> <ul> <li>GrapesJS drag-and-drop requires precise mouse interactions</li> <li>Small screens can't fit 3-panel layout (blocks, canvas, properties)</li> <li>Touch support experimental (grapesjs-touch plugin unstable)</li> </ul> <p>Alternative for mobile admins:</p> <ul> <li>Use CODE mode (Monaco editor works on mobile)</li> <li>Edit on desktop, preview on mobile</li> <li>Use responsive design testing tools</li> </ul>"},{"location":"v2/features/pages/grapes-editor/#data-flow-patterns","title":"Data Flow Patterns","text":""},{"location":"v2/features/pages/grapes-editor/#initial-load","title":"Initial Load","text":"<pre><code>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</code></pre> <p>Key Points:</p> <ul> <li><code>blocks</code> field contains full GrapesJS <code>projectData</code> (components tree, styles, assets)</li> <li>Empty object <code>{}</code> for new pages (GrapesJS shows blank canvas)</li> <li>Large JSON (50KB+) loads in ~200ms</li> </ul>"},{"location":"v2/features/pages/grapes-editor/#save-flow","title":"Save Flow","text":"<pre><code>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</code></pre> <p>Critical Detail:</p> <ul> <li><code>getProjectData()</code> returns full editor state (for future edits)</li> <li><code>getHtml()</code> returns rendered HTML (for public display)</li> <li><code>getCss()</code> returns compiled CSS (for public display)</li> <li>All three saved to database (different use cases)</li> </ul>"},{"location":"v2/features/pages/grapes-editor/#code-examples","title":"Code Examples","text":""},{"location":"v2/features/pages/grapes-editor/#complete-integration-example","title":"Complete Integration Example","text":"<pre><code>// 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</code></pre>"},{"location":"v2/features/pages/grapes-editor/#custom-block-registration","title":"Custom Block Registration","text":"<pre><code>// 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</code></pre>"},{"location":"v2/features/pages/grapes-editor/#adding-custom-html-generation","title":"Adding Custom HTML Generation","text":"<pre><code>// 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</code></pre>"},{"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":"<p>Symptoms:</p> <ul> <li>Custom blocks array passed to GrapesJSEditor</li> <li>Left panel shows default blocks only</li> <li>No campaign-specific blocks</li> </ul> <p>Causes:</p> <ol> <li><code>generateBlockHtml()</code> missing case for block type</li> <li>Category name mismatch</li> <li>Block registration timing issue</li> </ol> <p>Solutions:</p> <ol> <li> <p>Add case to generateBlockHtml(): <pre><code>case 'my-custom-block':\n return `<section>My custom block HTML</section>`;\n</code></pre></p> </li> <li> <p>Check category: <pre><code>// Block category: \"Campaign\"\n// GrapesJS shows blocks in collapsible \"Campaign\" section\n// Case-sensitive match\n</code></pre></p> </li> <li> <p>Verify registration timing: <pre><code>// Registration happens in useEffect after init\nconsole.log('Registering blocks:', customBlocks.length);\n</code></pre></p> </li> <li> <p>Inspect BlockManager: <pre><code>// 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</code></pre></p> </li> </ol>"},{"location":"v2/features/pages/grapes-editor/#problem-save-not-triggering","title":"Problem: Save Not Triggering","text":"<p>Symptoms:</p> <ul> <li>Press Ctrl+S \u2192 Nothing happens</li> <li>Manual save button doesn't work</li> <li><code>onSave</code> callback never called</li> </ul> <p>Causes:</p> <ol> <li>Keyboard event listener not registered</li> <li>forwardRef not working</li> <li><code>save-page</code> command not registered</li> </ol> <p>Solutions:</p> <ol> <li> <p>Check keyboard listener: <pre><code>// 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</code></pre></p> </li> <li> <p>Verify ref handle: <pre><code>// In parent component\nconsole.log('Editor ref:', editorRef.current); // Should be { triggerSave: fn }\n</code></pre></p> </li> <li> <p>Test command directly: <pre><code>// In browser console (after editor loads)\nwindow.editor.runCommand('save-page');\n// Should trigger onSave callback\n</code></pre></p> </li> <li> <p>Check onSaveRef pattern: <pre><code>const onSaveRef = useRef(onSave);\nonSaveRef.current = onSave; // Update on every render\n</code></pre></p> </li> </ol>"},{"location":"v2/features/pages/grapes-editor/#problem-editor-crashes-on-large-pages","title":"Problem: Editor Crashes on Large Pages","text":"<p>Symptoms:</p> <ul> <li>Loading page with 100+ components \u2192 Tab freezes</li> <li>GrapesJS UI unresponsive</li> <li>Save takes 10+ seconds</li> </ul> <p>Causes:</p> <ul> <li>Too many components in single page</li> <li>Deep nesting (10+ levels)</li> <li>Heavy images without lazy loading</li> </ul> <p>Solutions:</p> <ol> <li>Split into multiple pages:</li> <li>Separate hero, features, testimonials into 3 pages</li> <li> <p>Link pages via navigation</p> </li> <li> <p>Use CODE mode for complex layouts:</p> </li> <li>Write HTML directly \u2192 Faster than GrapesJS rendering</li> <li> <p>Import via \"Sync Overrides\"</p> </li> <li> <p>Optimize images:</p> </li> <li>Use external CDN (not base64-encoded)</li> <li>Compress before upload</li> <li> <p>Lazy load below fold</p> </li> <li> <p>Increase browser memory:</p> </li> <li>Chrome \u2192 <code>--max-old-space-size=4096</code></li> <li>Edge \u2192 Similar flag</li> </ol>"},{"location":"v2/features/pages/grapes-editor/#problem-initial-data-not-loading","title":"Problem: Initial Data Not Loading","text":"<p>Symptoms:</p> <ul> <li>Editor opens with blank canvas</li> <li><code>initialData</code> prop has data</li> <li>Console shows no errors</li> </ul> <p>Causes:</p> <ol> <li><code>loadProjectData()</code> called before editor ready</li> <li>Invalid JSON structure</li> <li>Async timing issue</li> </ol> <p>Solutions:</p> <ol> <li> <p>Check editor ready state: <pre><code>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</code></pre></p> </li> <li> <p>Validate JSON: <pre><code>console.log('Loading data:', JSON.stringify(initialData, null, 2));\n// Should have keys: assets, styles, pages\n</code></pre></p> </li> <li> <p>Handle empty data: <pre><code>if (initialData && Object.keys(initialData).length > 0) {\n editor.loadProjectData(initialData);\n} else {\n console.log('Starting with blank canvas');\n}\n</code></pre></p> </li> </ol>"},{"location":"v2/features/pages/grapes-editor/#problem-styles-not-applying-in-canvas","title":"Problem: Styles Not Applying in Canvas","text":"<p>Symptoms:</p> <ul> <li>Drag block to canvas \u2192 No background color</li> <li>Text has wrong font</li> <li>Layout broken</li> </ul> <p>Causes:</p> <ol> <li>Inline styles not supported</li> <li>External stylesheet missing</li> <li>Canvas iframe CSP issue</li> </ol> <p>Solutions:</p> <ol> <li> <p>Use inline styles in generateBlockHtml(): <pre><code>// Good\nreturn `<section style=\"padding: 40px; background: #f00;\">...</section>`;\n\n// Bad (requires CSS injection)\nreturn `<section class=\"hero\">...</section>`;\n</code></pre></p> </li> <li> <p>Inject fonts into canvas: <pre><code>canvas: {\n styles: [\n 'https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap',\n ],\n}\n</code></pre></p> </li> <li> <p>Check iframe sandbox: <pre><code>// GrapesJS canvas uses <iframe> \u2014 ensure no sandbox restrictions\n// Default config works, but custom CSP may block\n</code></pre></p> </li> </ol>"},{"location":"v2/features/pages/grapes-editor/#performance-optimization","title":"Performance Optimization","text":""},{"location":"v2/features/pages/grapes-editor/#lazy-loading","title":"Lazy Loading","text":"<pre><code>// 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</code></pre> <p>Benefit: Reduces initial bundle size by ~800KB (GrapesJS + plugins)</p>"},{"location":"v2/features/pages/grapes-editor/#debounced-auto-save","title":"Debounced Auto-Save","text":"<pre><code>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</code></pre> <p>Trade-off: More API calls vs. reduced data loss risk</p>"},{"location":"v2/features/pages/grapes-editor/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/features/pages/grapes-editor/#components","title":"Components","text":"<ul> <li>LandingPageEditor \u2014 Full-screen editor wrapper</li> <li>LandingPagesPage \u2014 Table view with edit links</li> </ul>"},{"location":"v2/features/pages/grapes-editor/#features","title":"Features","text":"<ul> <li>Page Builder \u2014 Complete page builder system</li> <li>Block Library \u2014 Custom blocks database</li> <li>MkDocs Export \u2014 Export to documentation site</li> </ul>"},{"location":"v2/features/pages/grapes-editor/#external","title":"External","text":"<ul> <li>GrapesJS Docs \u2014 Official documentation</li> <li>GrapesJS API \u2014 JavaScript API reference</li> <li>GrapesJS Plugins \u2014 Plugin ecosystem</li> </ul>"},{"location":"v2/features/pages/mkdocs-export/","title":"MkDocs Export Integration","text":"<p>Export landing pages to MkDocs Material theme with Jinja2 template wrapping, front matter configuration, and synchronized stub files.</p>"},{"location":"v2/features/pages/mkdocs-export/#overview","title":"Overview","text":"<p>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.</p>"},{"location":"v2/features/pages/mkdocs-export/#key-features","title":"Key Features","text":"<ul> <li>Two Export Modes: THEMED (extends Material theme) vs STANDALONE (full HTML document)</li> <li>Jinja2 Template Wrapping: Integrates with MkDocs Material theme inheritance</li> <li>Front Matter Configuration: Control navigation, table of contents visibility</li> <li>Dual-File Output: HTML override + Markdown stub</li> <li>Automatic Sync: Export triggered on publish, cleanup on unpublish</li> <li>Path Validation: Prevents directory traversal attacks</li> <li>Stub Backfill: Repair missing files via \"Validate Exports\"</li> </ul>"},{"location":"v2/features/pages/mkdocs-export/#architecture","title":"Architecture","text":"<pre><code>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</code></pre> <p>Flow:</p> <ol> <li>Trigger: Admin publishes page (or updates published page)</li> <li>Service: <code>pages.service.update()</code> checks publish status</li> <li>Export: Calls <code>exportToMkDocs()</code> with page data</li> <li>Wrap: HTML wrapped in Jinja2 <code>{% extends \"main.html\" %}</code></li> <li>Write: Two files created:</li> <li><code>mkdocs/docs/overrides/{slug}.html</code> \u2014 HTML override</li> <li><code>mkdocs/docs/{slug}.md</code> \u2014 Markdown stub</li> <li>Build: MkDocs rebuild (<code>mkdocs build</code>)</li> <li>Render: Stub references override, Material theme applies</li> <li>Serve: Page accessible at <code>https://cmlite.org/pages/{slug}/</code></li> </ol>"},{"location":"v2/features/pages/mkdocs-export/#export-modes","title":"Export Modes","text":""},{"location":"v2/features/pages/mkdocs-export/#themed-mode-default","title":"THEMED Mode (Default)","text":"<p>Purpose: Integrate page with MkDocs Material theme (header, footer, navigation)</p> <p>Jinja2 Template:</p> <pre><code>{% 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</code></pre> <p>Features:</p> <ul> <li>Uses Material theme header/footer</li> <li>Respects site navigation</li> <li>Table of contents auto-generated</li> <li>Search integration works</li> <li>Responsive design inherited</li> </ul> <p>Use Cases:</p> <ul> <li>Documentation pages</li> <li>Campaign info pages</li> <li>Community guidelines</li> </ul>"},{"location":"v2/features/pages/mkdocs-export/#standalone-mode","title":"STANDALONE Mode","text":"<p>Purpose: Full control over HTML (no MkDocs chrome)</p> <p>HTML Document:</p> <pre><code><!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</code></pre> <p>Features:</p> <ul> <li>No Material theme elements</li> <li>Custom head section (meta tags, styles)</li> <li>Independent from site navigation</li> <li>Full design freedom</li> </ul> <p>Use Cases:</p> <ul> <li>Marketing landing pages (like <code>lander.html</code>)</li> <li>Event registration pages</li> <li>Embedded pages (iframes)</li> </ul>"},{"location":"v2/features/pages/mkdocs-export/#file-outputs","title":"File Outputs","text":""},{"location":"v2/features/pages/mkdocs-export/#override-file-html","title":"Override File (.html)","text":"<p>Location: <code>mkdocs/docs/overrides/{slug}.html</code></p> <p>Example: <code>mkdocs/docs/overrides/about-us.html</code></p> <p>Content (THEMED mode):</p> <pre><code>{% extends \"main.html\" %}\n{% block content %}\n<style>\n/* Page CSS */\n</style>\n<!-- Page HTML -->\n{% endblock %}\n</code></pre> <p>Content (STANDALONE mode):</p> <pre><code><!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</code></pre> <p>Access Control:</p> <ul> <li>Readable by MkDocs build process</li> <li>Not directly served (accessed via stub)</li> </ul>"},{"location":"v2/features/pages/mkdocs-export/#stub-file-md","title":"Stub File (.md)","text":"<p>Location: <code>mkdocs/docs/{slug}.md</code></p> <p>Example: <code>mkdocs/docs/about-us.md</code></p> <p>Content:</p> <pre><code>---\ntemplate: about-us.html\ntitle: \"About Us | Campaign 2026\"\ndescription: \"Join our movement for change.\"\nhide:\n - navigation\n - toc\n---\n</code></pre> <p>Front Matter Fields:</p> Field Type Description <code>template</code> string Override filename (relative to <code>custom_dir</code>) <code>title</code> string Page title (from <code>seoTitle</code> or <code>title</code>) <code>description</code> string Meta description (from <code>seoDescription</code>) <code>hide</code> array Hide navigation/toc elements <p>Important: Template path is relative to <code>custom_dir</code> (<code>mkdocs/overrides/</code>). Use <code>about-us.html</code>, NOT <code>overrides/about-us.html</code> (causes <code>TemplateNotFound</code> error).</p>"},{"location":"v2/features/pages/mkdocs-export/#database-fields","title":"Database Fields","text":""},{"location":"v2/features/pages/mkdocs-export/#landingpage-export-configuration","title":"LandingPage Export Configuration","text":"<p>Fields:</p> Field Type Default Description <code>mkdocsPath</code> String? <code>{slug}.html</code> Override filename (auto-generated from slug) <code>mkdocsStubPath</code> String? <code>{slug}.md</code> Stub filename (derived from mkdocsPath) <code>mkdocsExportMode</code> Enum <code>THEMED</code> <code>THEMED</code> or <code>STANDALONE</code> <code>mkdocsHideNav</code> Boolean <code>false</code> Hide navigation sidebar (THEMED only) <code>mkdocsHideToc</code> Boolean <code>false</code> Hide table of contents (THEMED only) <code>mkdocsSkipExport</code> Boolean <code>false</code> Skip MkDocs export entirely <p>Behavior:</p> <ul> <li>On publish (<code>published=true</code>):</li> <li>If <code>mkdocsSkipExport=false</code>: Export files</li> <li>If <code>mkdocsSkipExport=true</code>: No export (page only at <code>/p/:slug</code>)</li> <li>On unpublish (<code>published=false</code>): Remove export files</li> <li>On title change: Regenerate slug, update <code>mkdocsPath</code>, clean up old files</li> </ul>"},{"location":"v2/features/pages/mkdocs-export/#admin-workflow","title":"Admin Workflow","text":""},{"location":"v2/features/pages/mkdocs-export/#exporting-a-page","title":"Exporting a Page","text":"<p>Automatic Export (on publish):</p> <ol> <li>Admin \u2192 Pages \u2192 Click \"Publish\" button</li> <li>API updates <code>published=true</code></li> <li>Service checks <code>mkdocsSkipExport</code></li> <li>If <code>false</code>: Calls <code>exportToMkDocs()</code></li> <li>Files written to disk</li> <li>Database updated with <code>mkdocsStubPath</code></li> </ol> <p>Manual Export Trigger:</p> <ol> <li>Edit page settings</li> <li>Change <code>mkdocsExportMode</code> or <code>mkdocsHideNav</code></li> <li>Save settings</li> <li>If published: Auto re-exports</li> </ol>"},{"location":"v2/features/pages/mkdocs-export/#configuring-export-options","title":"Configuring Export Options","text":"<p>Location: Page Settings modal \u2192 MkDocs Integration section</p> <p>Steps:</p> <ol> <li>Admin \u2192 Pages \u2192 Click gear icon (Settings)</li> <li>Scroll to \"MkDocs Integration\"</li> <li>Configure options:</li> <li>Skip MkDocs Export: \u2610 (unchecked)</li> <li>Override Path: <code>about.html</code> (auto-filled)</li> <li>Full page MkDocs: \u2610 (THEMED mode)</li> <li>Hide navigation sidebar: \u2611 (checked)</li> <li>Hide table of contents: \u2611 (checked)</li> <li>Click \"Save\"</li> <li>If published: Files re-exported immediately</li> </ol>"},{"location":"v2/features/pages/mkdocs-export/#rebuilding-mkdocs-site","title":"Rebuilding MkDocs Site","text":"<p>Trigger: After exporting pages</p> <p>Methods:</p> <p>Option 1: Admin UI</p> <ol> <li>Admin \u2192 Pages \u2192 \"Build Site\" button (SUPER_ADMIN only)</li> <li>Confirmation modal appears</li> <li>Click \"Confirm\"</li> <li>API executes <code>docker compose exec mkdocs mkdocs build</code></li> <li>Success notification</li> </ol> <p>Option 2: Command Line</p> <pre><code>docker compose exec mkdocs mkdocs build\n# Rebuilds site from mkdocs/docs/ directory\n# Output: mkdocs/site/ (static HTML)\n</code></pre> <p>Auto-rebuild: Not implemented (manual trigger required)</p>"},{"location":"v2/features/pages/mkdocs-export/#syncing-overrides","title":"Syncing Overrides","text":"<p>Purpose: Import hand-coded <code>.html</code> files from <code>overrides/</code> directory</p> <p>Workflow:</p> <ol> <li>Place <code>.html</code> file in <code>mkdocs/docs/overrides/custom.html</code></li> <li>Admin \u2192 Pages \u2192 \"Sync Overrides\" button</li> <li>API scans directory:</li> <li>Untracked files \u2192 Create CODE-mode page</li> <li>Tracked CODE-mode pages \u2192 Update <code>htmlOutput</code> from disk</li> <li>VISUAL pages \u2192 Skip (managed by GrapesJS)</li> <li>Backfills missing <code>.md</code> stubs</li> <li>Shows result: <code>Synced: 2 imported, 1 updated, 3 stubs created</code></li> </ol> <p>Use Cases:</p> <ul> <li>Migrate legacy templates</li> <li>Import designer-created HTML</li> <li>Restore after file system corruption</li> </ul>"},{"location":"v2/features/pages/mkdocs-export/#validating-exports","title":"Validating Exports","text":"<p>Purpose: Verify files exist on disk, repair if missing</p> <p>Workflow:</p> <ol> <li>Admin \u2192 Pages \u2192 \"Validate Exports\" button</li> <li>API queries all published, non-skipped pages</li> <li>For each page:</li> <li>Check <code>.html</code> override exists</li> <li>Check <code>.md</code> stub exists</li> <li>If either missing: Re-export</li> <li>Shows result: <code>Validated 10 pages: 2 repaired, 0 errors</code></li> </ol> <p>Use Cases:</p> <ul> <li>Recover from accidental deletion</li> <li>Fix state after container restarts</li> <li>Audit before production deploy</li> </ul>"},{"location":"v2/features/pages/mkdocs-export/#code-examples","title":"Code Examples","text":""},{"location":"v2/features/pages/mkdocs-export/#themed-mode-export","title":"Themed Mode Export","text":"<pre><code>// 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</code></pre>"},{"location":"v2/features/pages/mkdocs-export/#standalone-mode-export","title":"Standalone Mode Export","text":"<pre><code>function wrapInStandaloneDocument(\n html: string,\n css: string | null,\n title: string,\n description: string | null\n): string {\n const metaDesc = description\n ? `\\n <meta name=\"description\" content=\"${description.replace(/\"/g, '&quot;')}\">`\n : '';\n const styleBlock = css ? `\\n <style>\\n${css}\\n </style>` : '';\n\n return `<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <title>${title.replace(/</g, '&lt;')}</title>${metaDesc}${styleBlock}\n</head>\n<body>\n${html}\n</body>\n</html>\n`;\n}\n\n// Usage\nconst content = wrapInStandaloneDocument(\n '<section><h1>About Us</h1></section>',\n 'section { padding: 40px; }',\n 'About Us | Campaign 2026',\n 'Join our movement for change.'\n);\n</code></pre>"},{"location":"v2/features/pages/mkdocs-export/#markdown-stub-generation","title":"Markdown Stub Generation","text":"<pre><code>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</code></pre>"},{"location":"v2/features/pages/mkdocs-export/#export-orchestration","title":"Export Orchestration","text":"<pre><code>// 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</code></pre>"},{"location":"v2/features/pages/mkdocs-export/#path-validation","title":"Path Validation","text":"<pre><code>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</code></pre>"},{"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":"<p>Required Configuration:</p> <pre><code>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</code></pre> <p>Key Points:</p> <ul> <li><code>custom_dir: overrides</code> \u2014 Enables template overrides</li> <li>Stub files must be listed in <code>nav</code> to appear in navigation</li> <li>Unlisted stubs still accessible via direct URL</li> </ul>"},{"location":"v2/features/pages/mkdocs-export/#template-search-paths","title":"Template Search Paths","text":"<p>MkDocs Material searches:</p> <ol> <li><code>mkdocs/overrides/</code> (custom_dir)</li> <li>Material theme templates</li> <li>MkDocs core templates</li> </ol> <p>Resolution:</p> <pre><code># 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</code></pre> <p>Common Mistake:</p> <pre><code># WRONG - causes TemplateNotFound\ntemplate: overrides/about-us.html\n\n# MkDocs searches:\n# 1. mkdocs/overrides/overrides/about-us.html \u2717 (not found)\n</code></pre> <p>Solution: Use filename only, not path with <code>overrides/</code>.</p>"},{"location":"v2/features/pages/mkdocs-export/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/features/pages/mkdocs-export/#problem-template-not-found-error","title":"Problem: Template Not Found Error","text":"<p>Symptoms:</p> <ul> <li>MkDocs build fails</li> <li>Error: <code>jinja2.exceptions.TemplateNotFound: overrides/about-us.html</code></li> </ul> <p>Causes:</p> <ol> <li>Stub uses <code>template: overrides/about-us.html</code> (incorrect path)</li> <li><code>custom_dir</code> not configured in <code>mkdocs.yml</code></li> <li>Override file doesn't exist</li> </ol> <p>Solutions:</p> <ol> <li> <p>Fix stub front matter: <pre><code># Before (wrong)\ntemplate: overrides/about-us.html\n\n# After (correct)\ntemplate: about-us.html\n</code></pre></p> </li> <li> <p>Verify custom_dir: <pre><code># In mkdocs.yml\ntheme:\n name: material\n custom_dir: overrides\n</code></pre></p> </li> <li> <p>Check file exists: <pre><code>ls -la mkdocs/docs/overrides/about-us.html\n# Should exist if page published\n</code></pre></p> </li> <li> <p>Validate exports:</p> </li> <li>Admin \u2192 Pages \u2192 \"Validate Exports\"</li> <li>Repairs missing files</li> </ol>"},{"location":"v2/features/pages/mkdocs-export/#problem-export-files-missing-after-restart","title":"Problem: Export Files Missing After Restart","text":"<p>Symptoms:</p> <ul> <li>Pages were published before restart</li> <li>After <code>docker compose restart</code>: Files gone</li> <li>MkDocs build fails</li> </ul> <p>Causes:</p> <ol> <li>Volume mount not configured</li> <li>Files written to container filesystem (not host)</li> <li>Container recreated (ephemeral storage lost)</li> </ol> <p>Solutions:</p> <ol> <li> <p>Check volume mount: <pre><code># In docker-compose.yml\nservices:\n api:\n volumes:\n - ./mkdocs:/mkdocs:rw # Must have :rw for write access\n</code></pre></p> </li> <li> <p>Verify host files: <pre><code>ls -la mkdocs/docs/overrides/\n# Files should persist on host filesystem\n</code></pre></p> </li> <li> <p>Re-export all pages:</p> </li> <li>Admin \u2192 Pages \u2192 \"Validate Exports\"</li> <li>Regenerates all missing files</li> </ol>"},{"location":"v2/features/pages/mkdocs-export/#problem-page-not-appearing-in-mkdocs-site","title":"Problem: Page Not Appearing in MkDocs Site","text":"<p>Symptoms:</p> <ul> <li>Page published, files exist</li> <li>MkDocs builds successfully</li> <li>Page shows 404 on site</li> </ul> <p>Causes:</p> <ol> <li>Stub not listed in <code>mkdocs.yml</code> nav</li> <li>MkDocs not rebuilt after export</li> <li>Nginx cache serving old version</li> </ol> <p>Solutions:</p> <ol> <li> <p>Add to nav (optional): <pre><code>nav:\n - Pages:\n - About: about-us.md # Stub filename\n</code></pre></p> </li> <li> <p>Rebuild MkDocs: <pre><code>docker compose exec mkdocs mkdocs build\n# Or Admin \u2192 Pages \u2192 \"Build Site\"\n</code></pre></p> </li> <li> <p>Clear Nginx cache: <pre><code>docker compose exec nginx nginx -s reload\n</code></pre></p> </li> <li> <p>Test direct access: <pre><code>curl http://localhost:4001/pages/about-us/\n# Should return HTML, not 404\n</code></pre></p> </li> </ol>"},{"location":"v2/features/pages/mkdocs-export/#problem-styles-not-applying-in-mkdocs","title":"Problem: Styles Not Applying in MkDocs","text":"<p>Symptoms:</p> <ul> <li>Page renders in GrapesJS editor</li> <li>MkDocs site shows unstyled content</li> </ul> <p>Causes:</p> <ol> <li>CSS not exported (CODE mode without <code>cssOutput</code>)</li> <li>Material theme CSS conflicts</li> <li>Inline styles overridden</li> </ol> <p>Solutions:</p> <ol> <li> <p>Check cssOutput field: <pre><code>SELECT css_output FROM landing_pages WHERE slug = 'about-us';\n-- Should contain CSS, not NULL\n</code></pre></p> </li> <li> <p>Inspect rendered HTML: <pre><code>curl http://localhost:4001/pages/about-us/ | grep '<style>'\n# Should include page CSS\n</code></pre></p> </li> <li> <p>Use !important for overrides: <pre><code>/* In page CSS */\nsection {\n padding: 40px !important;\n}\n</code></pre></p> </li> <li> <p>Test STANDALONE mode:</p> </li> <li>Settings \u2192 Full page MkDocs (checked)</li> <li>Bypasses Material theme CSS</li> </ol>"},{"location":"v2/features/pages/mkdocs-export/#problem-hide-navigation-not-working","title":"Problem: Hide Navigation Not Working","text":"<p>Symptoms:</p> <ul> <li>Page settings: <code>mkdocsHideNav=true</code></li> <li>Navigation sidebar still shows</li> </ul> <p>Causes:</p> <ol> <li>Stub front matter not updated</li> <li>MkDocs cache not cleared</li> <li>STANDALONE mode enabled (hide options ignored)</li> </ol> <p>Solutions:</p> <ol> <li> <p>Check stub front matter: <pre><code>cat mkdocs/docs/about-us.md\n# Should have:\n# hide:\n# - navigation\n</code></pre></p> </li> <li> <p>Re-export:</p> </li> <li>Edit page settings \u2192 Save</li> <li> <p>Triggers stub regeneration</p> </li> <li> <p>Clear MkDocs cache: <pre><code>rm -rf mkdocs/site/\ndocker compose exec mkdocs mkdocs build\n</code></pre></p> </li> <li> <p>Verify not STANDALONE:</p> </li> <li>Settings \u2192 Full page MkDocs (unchecked)</li> <li>STANDALONE ignores hide options</li> </ol>"},{"location":"v2/features/pages/mkdocs-export/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/features/pages/mkdocs-export/#file-system-io","title":"File System I/O","text":"<p>Export operation: Writes 2 files per page (~1ms each)</p> <p>Bottleneck: Synchronous file writes in API request handler</p> <p>Impact:</p> <ul> <li>Publish operation: +2ms overhead</li> <li>Batch operations: Linear scaling (10 pages = +20ms)</li> </ul> <p>Optimization (future):</p> <pre><code>// Current: Synchronous writes in request\nawait fs.writeFile(path, content);\n\n// Future: Background job queue\nawait queue.add('export-page', { pageId });\n</code></pre>"},{"location":"v2/features/pages/mkdocs-export/#mkdocs-build-time","title":"MkDocs Build Time","text":"<p>Build duration: Proportional to page count</p> <ul> <li>10 pages: ~2 seconds</li> <li>100 pages: ~10 seconds</li> <li>1000 pages: ~90 seconds</li> </ul> <p>Optimization:</p> <ul> <li>Use <code>mkdocs serve --dirtyreload</code> in dev (incremental builds)</li> <li>Production builds: Full rebuild recommended</li> </ul>"},{"location":"v2/features/pages/mkdocs-export/#security-considerations","title":"Security Considerations","text":""},{"location":"v2/features/pages/mkdocs-export/#path-traversal-protection","title":"Path Traversal Protection","text":"<p>Validation:</p> <ol> <li>Null byte check: Prevents <code>about\\0.html</code> attacks</li> <li>Normalization: <code>path.normalize()</code> resolves <code>../</code></li> <li>Absolute path check: Rejects <code>/etc/passwd.html</code></li> <li>Encoded traversal: Blocks <code>%2e%2e/admin.html</code></li> <li>Extension validation: Must end with <code>.html</code></li> </ol> <p>Rejected Paths:</p> <ul> <li><code>../../../etc/passwd.html</code></li> <li><code>/var/www/config.html</code></li> <li><code>admin%2e%2e%2fconfig.html</code></li> <li><code>about.md</code> (wrong extension)</li> </ul>"},{"location":"v2/features/pages/mkdocs-export/#file-permission-isolation","title":"File Permission Isolation","text":"<p>Docker Volume Mount:</p> <pre><code>volumes:\n - ./mkdocs:/mkdocs:rw\n</code></pre> <p>Permissions:</p> <ul> <li>API container writes as <code>node</code> user (UID 1000)</li> <li>Host user must have write access to <code>mkdocs/docs/</code></li> <li>MkDocs container reads as <code>mkdocs</code> user (UID 1001)</li> </ul> <p>Risk: Container escape could write arbitrary files</p> <p>Mitigation:</p> <ul> <li>API container runs as non-root user</li> <li>Volume mount scoped to <code>/mkdocs</code> only (no host root access)</li> </ul>"},{"location":"v2/features/pages/mkdocs-export/#template-injection","title":"Template Injection","text":"<p>Risk: Malicious admin injects Jinja2 code</p> <p>Example:</p> <pre><code><!-- Malicious HTML in editor -->\n<h1>{{ config.site_name }}</h1>\n</code></pre> <p>Rendering:</p> <ul> <li>THEMED mode: Jinja2 processes <code>{{ }}</code> expressions</li> <li>Could expose MkDocs config or Material theme internals</li> </ul> <p>Mitigation:</p> <ul> <li>Accepted risk: Admins are trusted users</li> <li>Template code only renders in MkDocs (isolated from main app)</li> <li>Public users cannot edit landing pages</li> </ul>"},{"location":"v2/features/pages/mkdocs-export/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/features/pages/mkdocs-export/#frontend-components","title":"Frontend Components","text":"<ul> <li>LandingPagesPage \u2014 Export buttons + validation</li> <li>PageEditorPage \u2014 Auto-export on publish</li> </ul>"},{"location":"v2/features/pages/mkdocs-export/#backend-modules","title":"Backend Modules","text":"<ul> <li>pages.service \u2014 Export logic (<code>exportToMkDocs</code>, <code>validateExports</code>, <code>syncOverrides</code>)</li> <li>pages-admin.routes \u2014 <code>/sync</code> and <code>/validate</code> endpoints</li> </ul>"},{"location":"v2/features/pages/mkdocs-export/#features","title":"Features","text":"<ul> <li>Page Builder \u2014 Landing page system overview</li> <li>GrapesJS Editor \u2014 Editor integration</li> <li>Block Library \u2014 Reusable blocks</li> </ul>"},{"location":"v2/features/pages/mkdocs-export/#mkdocs-resources","title":"MkDocs Resources","text":"<ul> <li>MkDocs Material Templates \u2014 Theme customization</li> <li>Jinja2 Documentation \u2014 Template syntax</li> <li>MkDocs Configuration \u2014 mkdocs.yml reference</li> </ul>"},{"location":"v2/features/pages/mkdocs-export/#deployment","title":"Deployment","text":"<ul> <li>Docker Setup \u2014 Volume mounts + permissions</li> <li>MkDocs Service \u2014 Container configuration</li> </ul>"},{"location":"v2/features/pages/page-builder/","title":"Page Builder","text":"<p>Complete WYSIWYG landing page builder with GrapesJS editor, slug-based public routing, and MkDocs Material theme integration.</p>"},{"location":"v2/features/pages/page-builder/#overview","title":"Overview","text":"<p>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.</p>"},{"location":"v2/features/pages/page-builder/#key-features","title":"Key Features","text":"<ul> <li>Dual-Mode Editing: Switch between VISUAL (GrapesJS drag-and-drop) and CODE (raw HTML editor)</li> <li>Slug-Based Routing: Public pages accessible at <code>/p/:slug</code> (e.g., <code>/p/about-us</code>)</li> <li>MkDocs Export: Publish pages to MkDocs documentation site with Material theme integration</li> <li>SEO Meta Tags: Configure title, description, and Open Graph images</li> <li>Custom Blocks: Reusable components (hero, features, CTA, testimonials, contact forms)</li> <li>Video Integration: Embed media library videos with standard or advanced players</li> <li>Mobile Detection: Editor warns users on small screens (desktop-only editing)</li> </ul>"},{"location":"v2/features/pages/page-builder/#architecture-overview","title":"Architecture Overview","text":"<pre><code>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</code></pre> <p>Flow:</p> <ol> <li>Admin creates page via LandingPagesPage</li> <li>Editor loads with GrapesJS (VISUAL mode) or Monaco (CODE mode)</li> <li>Admin drags blocks, configures properties, saves (Ctrl+S)</li> <li>API stores <code>projectData</code> (GrapesJS JSON), <code>htmlOutput</code>, <code>cssOutput</code></li> <li>On publish: API exports <code>.html</code> override + <code>.md</code> stub to MkDocs</li> <li>Public users access page at <code>/p/:slug</code> (React route renders HTML)</li> </ol>"},{"location":"v2/features/pages/page-builder/#database-models","title":"Database Models","text":""},{"location":"v2/features/pages/page-builder/#landingpage","title":"LandingPage","text":"<p>Table: <code>landing_pages</code></p> <p>Key Fields:</p> Field Type Description <code>id</code> String (UUID) Primary key <code>slug</code> String Unique URL-safe identifier (auto-generated from title) <code>title</code> String Page title (internal + fallback SEO) <code>description</code> String? Page description (internal) <code>editorMode</code> Enum <code>VISUAL</code> (GrapesJS) or <code>CODE</code> (raw HTML) <code>blocks</code> JSON GrapesJS <code>projectData</code> (components tree) <code>htmlOutput</code> String? Rendered HTML (cached output from editor) <code>cssOutput</code> String? Rendered CSS (cached output from editor) <code>mkdocsPath</code> String? Override file path (e.g., <code>about.html</code>) <code>mkdocsStubPath</code> String? Stub Markdown path (e.g., <code>about.md</code>) <code>mkdocsExportMode</code> Enum <code>THEMED</code> (extends main.html) or <code>STANDALONE</code> (full HTML) <code>mkdocsHideNav</code> Boolean Hide navigation sidebar in MkDocs <code>mkdocsHideToc</code> Boolean Hide table of contents in MkDocs <code>mkdocsSkipExport</code> Boolean Don't export to MkDocs (only accessible via /p/:slug) <code>published</code> Boolean Public visibility (false = draft) <code>seoTitle</code> String? Custom SEO title (overrides <code>title</code>) <code>seoDescription</code> String? Meta description for search engines <code>seoImage</code> String? Open Graph image URL <code>createdAt</code> DateTime Creation timestamp <code>updatedAt</code> DateTime Last modification timestamp <p>Indexes:</p> <ul> <li><code>slug</code> (unique)</li> <li><code>published</code> (filter index)</li> </ul> <p>Relationships:</p> <ul> <li>None (standalone model)</li> </ul>"},{"location":"v2/features/pages/page-builder/#pageblock","title":"PageBlock","text":"<p>See Block Library documentation.</p>"},{"location":"v2/features/pages/page-builder/#api-endpoints","title":"API Endpoints","text":""},{"location":"v2/features/pages/page-builder/#admin-routes","title":"Admin Routes","text":"<p>Prefix: <code>/api/pages</code></p> <p>Authentication: Requires admin role (SUPER_ADMIN, INFLUENCE_ADMIN, or MAP_ADMIN)</p>"},{"location":"v2/features/pages/page-builder/#list-pages","title":"List Pages","text":"<pre><code>GET /api/pages?page=1&limit=20&search=campaign&published=true\n</code></pre> <p>Query Parameters:</p> <ul> <li><code>page</code> (number, default: 1) \u2014 Page number</li> <li><code>limit</code> (number, default: 20, max: 100) \u2014 Results per page</li> <li><code>search</code> (string?) \u2014 Search title, description, or slug (case-insensitive)</li> <li><code>published</code> (string?) \u2014 Filter by status: <code>\"true\"</code>, <code>\"false\"</code>, or omit for all</li> </ul> <p>Response:</p> <pre><code>{\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</code></pre>"},{"location":"v2/features/pages/page-builder/#get-page","title":"Get Page","text":"<pre><code>GET /api/pages/:id\n</code></pre> <p>Response: Single <code>LandingPage</code> object (same structure as list item above)</p> <p>Errors:</p> <ul> <li><code>404 PAGE_NOT_FOUND</code> \u2014 Page doesn't exist</li> </ul>"},{"location":"v2/features/pages/page-builder/#create-page","title":"Create Page","text":"<pre><code>POST /api/pages\nContent-Type: application/json\n\n{\n \"title\": \"New Landing Page\",\n \"description\": \"Page description\",\n \"editorMode\": \"VISUAL\"\n}\n</code></pre> <p>Request Body:</p> <ul> <li><code>title</code> (string, required) \u2014 Page title (slug auto-generated)</li> <li><code>description</code> (string?) \u2014 Internal description</li> <li><code>editorMode</code> (enum?, default: <code>VISUAL</code>) \u2014 <code>VISUAL</code> or <code>CODE</code></li> <li><code>mkdocsPath</code> (string?) \u2014 Custom override path (defaults to <code>{slug}.html</code>)</li> </ul> <p>Response: Created <code>LandingPage</code> object (201 status)</p> <p>Errors:</p> <ul> <li><code>400 INVALID_MKDOCS_PATH</code> \u2014 Invalid path (traversal attempt, missing .html extension)</li> </ul> <p>Behavior:</p> <ul> <li>Slug auto-generated from title (lowercased, spaces\u2192hyphens, alphanumeric only)</li> <li>Slug collision handling (appends <code>-2</code>, <code>-3</code>, etc.)</li> <li><code>blocks</code> initialized as empty JSON object</li> <li><code>published</code> defaults to <code>false</code></li> </ul>"},{"location":"v2/features/pages/page-builder/#update-page","title":"Update Page","text":"<pre><code>PUT /api/pages/:id\nContent-Type: application/json\n\n{\n \"blocks\": { /* GrapesJS projectData */ },\n \"htmlOutput\": \"<section>...</section>\",\n \"cssOutput\": \"section { padding: 40px; }\",\n \"published\": true\n}\n</code></pre> <p>Request Body: (all fields optional)</p> <ul> <li><code>title</code> (string?) \u2014 New title (regenerates slug if changed)</li> <li><code>description</code> (string?)</li> <li><code>blocks</code> (JSON?) \u2014 GrapesJS <code>projectData</code></li> <li><code>htmlOutput</code> (string?) \u2014 Rendered HTML</li> <li><code>cssOutput</code> (string?) \u2014 Rendered CSS</li> <li><code>published</code> (boolean?) \u2014 Publish status</li> <li><code>mkdocsPath</code> (string?) \u2014 Custom override path</li> <li><code>mkdocsExportMode</code> (enum?) \u2014 <code>THEMED</code> or <code>STANDALONE</code></li> <li><code>mkdocsHideNav</code> (boolean?)</li> <li><code>mkdocsHideToc</code> (boolean?)</li> <li><code>mkdocsSkipExport</code> (boolean?)</li> <li><code>seoTitle</code> (string?)</li> <li><code>seoDescription</code> (string?)</li> <li><code>seoImage</code> (string?)</li> </ul> <p>Response: Updated <code>LandingPage</code> object</p> <p>Errors:</p> <ul> <li><code>404 PAGE_NOT_FOUND</code> \u2014 Page doesn't exist</li> <li><code>400 INVALID_MKDOCS_PATH</code> \u2014 Invalid path</li> </ul> <p>Side Effects:</p> <ul> <li>On publish (published=true, mkdocsSkipExport=false): Exports to MkDocs (writes <code>.html</code> + <code>.md</code> stub)</li> <li>On unpublish or mkdocsSkipExport=true: Removes MkDocs files</li> <li>On title change: Regenerates slug, updates <code>mkdocsPath</code> if it was auto-generated, cleans up old exports</li> </ul>"},{"location":"v2/features/pages/page-builder/#delete-page","title":"Delete Page","text":"<pre><code>DELETE /api/pages/:id\n</code></pre> <p>Response: 204 No Content</p> <p>Errors:</p> <ul> <li><code>404 PAGE_NOT_FOUND</code> \u2014 Page doesn't exist</li> </ul> <p>Side Effects:</p> <ul> <li>Removes MkDocs exports (<code>.html</code> override + <code>.md</code> stub) if they exist</li> </ul>"},{"location":"v2/features/pages/page-builder/#sync-overrides","title":"Sync Overrides","text":"<pre><code>POST /api/pages/sync\n</code></pre> <p>Purpose: Import untracked <code>.html</code> files from <code>mkdocs/docs/overrides/</code> as CODE-mode pages. Useful for migrating hand-crafted HTML templates.</p> <p>Response:</p> <pre><code>{\n \"imported\": 2,\n \"updated\": 1,\n \"stubs\": 3\n}\n</code></pre> <p>Behavior:</p> <ol> <li>Scans <code>mkdocs/docs/overrides/</code> recursively for <code>.html</code> files</li> <li>For untracked files: Creates new CODE-mode page (published=true)</li> <li>For tracked CODE-mode pages: Updates <code>htmlOutput</code> from disk (disk wins)</li> <li>For tracked VISUAL-mode pages: Skips (managed by GrapesJS)</li> <li>Backfills missing <code>.md</code> stubs for published pages</li> </ol> <p>Use Cases:</p> <ul> <li>Migrate legacy hand-coded landing pages</li> <li>Import templates from designers</li> <li>Sync after manual file system edits</li> </ul>"},{"location":"v2/features/pages/page-builder/#validate-exports","title":"Validate Exports","text":"<pre><code>POST /api/pages/validate\n</code></pre> <p>Purpose: Verify MkDocs exports exist on disk, repair if missing.</p> <p>Response:</p> <pre><code>{\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</code></pre> <p>Behavior:</p> <ol> <li>Queries all published, non-skipped pages with <code>mkdocsPath</code></li> <li>Checks if <code>.html</code> override and <code>.md</code> stub exist</li> <li>Re-exports if either missing</li> <li>Updates <code>mkdocsStubPath</code> if changed</li> <li>Returns error list for manual intervention</li> </ol> <p>Use Cases:</p> <ul> <li>Recover from accidental file deletion</li> <li>Fix export state after container restarts</li> <li>Audit before MkDocs rebuild</li> </ul>"},{"location":"v2/features/pages/page-builder/#public-routes","title":"Public Routes","text":"<p>Prefix: <code>/api/pages</code></p> <p>Authentication: None (public access)</p>"},{"location":"v2/features/pages/page-builder/#view-published-page","title":"View Published Page","text":"<pre><code>GET /api/pages/:slug/view\n</code></pre> <p>Example:</p> <pre><code>GET /api/pages/about-us/view\n</code></pre> <p>Response:</p> <pre><code>{\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</code></pre> <p>Errors:</p> <ul> <li><code>404 PAGE_NOT_FOUND</code> \u2014 Page doesn't exist or is unpublished</li> </ul> <p>Security:</p> <ul> <li>Only returns published pages (<code>published=true</code>)</li> <li>Omits editor-only fields (<code>blocks</code>, <code>mkdocsPath</code>, etc.)</li> </ul>"},{"location":"v2/features/pages/page-builder/#configuration","title":"Configuration","text":""},{"location":"v2/features/pages/page-builder/#environment-variables","title":"Environment Variables","text":"<pre><code># MkDocs integration\nMKDOCS_DOCS_PATH=/mkdocs/docs\n# Override path: ${MKDOCS_DOCS_PATH}/overrides/\n# Stub path: ${MKDOCS_DOCS_PATH}/ (root of docs)\n</code></pre> <p>Docker Volume:</p> <pre><code>volumes:\n - ./mkdocs:/mkdocs:rw\n</code></pre> <p>Note: API container needs write access to export files.</p>"},{"location":"v2/features/pages/page-builder/#site-settings","title":"Site Settings","text":"<p>Feature Flag: <code>ENABLE_LANDING_PAGES</code></p> <p>Location: Admin \u2192 Settings \u2192 Features \u2192 Landing Pages</p> <p>Default: <code>true</code></p> <p>Effect: Shows/hides \"Pages\" menu item in admin sidebar</p>"},{"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":"<ol> <li>Navigate: Admin sidebar \u2192 Pages</li> <li>Click: \"Create Page\" button</li> <li>Fill form:</li> <li>Title: <code>\"About Us\"</code> (slug auto-generated: <code>about-us</code>)</li> <li>Description: <code>\"Learn about our campaign\"</code> (optional)</li> <li>Editor Mode: <code>VISUAL</code> (default) or <code>CODE</code></li> <li>Submit: \"Create & Edit\" button</li> <li>Result: Redirected to full-screen editor</li> </ol>"},{"location":"v2/features/pages/page-builder/#visual-editing-visual-mode","title":"Visual Editing (VISUAL Mode)","text":"<ol> <li>Editor opens: GrapesJS interface with 3 panels:</li> <li>Left: Block library (drag-and-drop components)</li> <li>Center: Canvas (preview + inline editing)</li> <li>Right: Properties panel (configure selected component)</li> <li>Add blocks: Drag \"Hero Section\" from left panel to canvas</li> <li>Configure: Click hero \u2192 Edit title/subtitle/CTA in right panel</li> <li>Save: Press <code>Ctrl+S</code> (or <code>Cmd+S</code> on Mac) \u2192 API saves <code>projectData</code>, <code>htmlOutput</code>, <code>cssOutput</code></li> <li>Close: Click \"X\" or \"Back to Pages\" \u2192 Returns to table</li> </ol>"},{"location":"v2/features/pages/page-builder/#code-editing-code-mode","title":"Code Editing (CODE Mode)","text":"<ol> <li>Editor opens: Split-view Monaco editors:</li> <li>Left: HTML editor</li> <li>Right: CSS editor (optional)</li> <li>Edit HTML: Write raw HTML with Jinja2 template syntax (for MkDocs)</li> <li>Save: Press <code>Ctrl+S</code> \u2192 API saves <code>htmlOutput</code>, <code>cssOutput</code></li> <li>Close: Click \"Back to Pages\"</li> </ol>"},{"location":"v2/features/pages/page-builder/#publishing-a-page","title":"Publishing a Page","text":"<p>Option 1: From Table</p> <ol> <li>Locate page in table</li> <li>Click \"Publish\" button in Actions column</li> <li>Status tag changes: Draft \u2192 Published</li> <li>Page accessible at <code>/p/{slug}</code></li> </ol> <p>Option 2: From Settings Modal</p> <ol> <li>Click gear icon (Settings) in Actions column</li> <li>Settings modal opens</li> <li>(Field not shown in modal \u2014 use table toggle)</li> </ol> <p>Side Effects (on publish):</p> <ul> <li>If <code>mkdocsSkipExport=false</code>: Exports <code>.html</code> + <code>.md</code> to MkDocs</li> <li>If <code>mkdocsSkipExport=true</code>: Only accessible via <code>/p/:slug</code> (no MkDocs export)</li> </ul>"},{"location":"v2/features/pages/page-builder/#configuring-seo","title":"Configuring SEO","text":"<ol> <li>Click gear icon (Settings) in Actions column</li> <li>Fill SEO section:</li> <li>SEO Title: Custom title for <code><title></code> and Open Graph (defaults to <code>title</code>)</li> <li>SEO Description: Meta description for search engines</li> <li>SEO Image: Full URL to Open Graph image (e.g., <code>https://cdn.example.com/og.jpg</code>)</li> <li>Click \"Save\"</li> <li>Re-export to MkDocs if already published</li> </ol>"},{"location":"v2/features/pages/page-builder/#mkdocs-integration-settings","title":"MkDocs Integration Settings","text":"<p>Access: Page Settings modal \u2192 MkDocs Integration section</p> <p>Fields:</p> <ol> <li>Skip MkDocs Export (checkbox)</li> <li>When enabled: Page NOT exported to MkDocs site</li> <li>Use case: Pages meant only for <code>/p/:slug</code> (not documentation)</li> <li> <p>Default: <code>false</code> (export enabled)</p> </li> <li> <p>Override Path (text input)</p> </li> <li>Custom filename for override (e.g., <code>custom-about.html</code>)</li> <li>Default: Auto-generated from slug (<code>{slug}.html</code>)</li> <li> <p>Validation: Must end with <code>.html</code>, no path traversal</p> </li> <li> <p>Full page MkDocs (checkbox)</p> </li> <li>When enabled: Exports as STANDALONE (full <code><!DOCTYPE html></code> document)</li> <li>When disabled: Exports as THEMED (wraps in <code>{% extends \"main.html\" %}</code>)</li> <li>Default: <code>false</code> (THEMED)</li> <li> <p>Use case: Standalone pages with no MkDocs chrome (like <code>lander.html</code>)</p> </li> <li> <p>Hide navigation sidebar (checkbox, only for THEMED mode)</p> </li> <li>Adds <code>hide: [navigation]</code> to <code>.md</code> stub front matter</li> <li>Hides left sidebar on page</li> <li> <p>Default: <code>false</code></p> </li> <li> <p>Hide table of contents (checkbox, only for THEMED mode)</p> </li> <li>Adds <code>hide: [toc]</code> to <code>.md</code> stub front matter</li> <li>Hides right sidebar on page</li> <li>Default: <code>false</code></li> </ol> <p>Workflow:</p> <ol> <li>Edit page settings</li> <li>Configure MkDocs options</li> <li>Save settings</li> <li>If published: API auto-exports with new settings</li> <li>Rebuild MkDocs: Admin \u2192 Pages \u2192 \"Build Site\" button</li> </ol>"},{"location":"v2/features/pages/page-builder/#syncing-overrides","title":"Syncing Overrides","text":"<p>Purpose: Import hand-coded <code>.html</code> files from disk</p> <p>Workflow:</p> <ol> <li>Place <code>.html</code> files in <code>mkdocs/docs/overrides/</code> (on Docker host)</li> <li>Admin \u2192 Pages \u2192 \"Sync Overrides\" button</li> <li>API scans directory, imports new files as CODE-mode pages</li> <li>Table refreshes, new pages appear</li> <li>Edit pages normally, publish as needed</li> </ol> <p>Example:</p> <pre><code># 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</code></pre>"},{"location":"v2/features/pages/page-builder/#validating-exports","title":"Validating Exports","text":"<p>Purpose: Verify MkDocs files exist, repair if missing</p> <p>Workflow:</p> <ol> <li>Admin \u2192 Pages \u2192 \"Validate Exports\" button</li> <li>API checks all published pages:</li> <li><code>.html</code> override exists?</li> <li><code>.md</code> stub exists?</li> <li>Re-exports if either missing</li> <li>Shows result: <code>Validated 10 pages: 2 repaired</code></li> </ol> <p>Use Cases:</p> <ul> <li>After container restart (volume mount issues)</li> <li>After manual file deletion</li> <li>Before rebuilding MkDocs site</li> </ul>"},{"location":"v2/features/pages/page-builder/#public-workflow","title":"Public Workflow","text":""},{"location":"v2/features/pages/page-builder/#viewing-a-published-page","title":"Viewing a Published Page","text":"<ol> <li>User navigates: <code>https://yoursite.com/p/about-us</code></li> <li>React router: Matches <code>/p/:slug</code> route \u2192 Loads <code>LandingPage.tsx</code></li> <li>API call: <code>GET /api/pages/about-us/view</code></li> <li>Response: Returns <code>htmlOutput</code>, <code>cssOutput</code>, SEO fields</li> <li>Render:</li> <li>Sets <code>document.title = seoTitle || title</code></li> <li>Updates meta description, Open Graph image</li> <li>Injects <code>cssOutput</code> as <code><style></code> tag</li> <li>Renders <code>htmlOutput</code> via <code>dangerouslySetInnerHTML</code></li> <li>Video hydration: Scans for <code>.video-block</code> divs, replaces placeholders with React VideoPlayer components</li> </ol>"},{"location":"v2/features/pages/page-builder/#seo-meta-tags","title":"SEO Meta Tags","text":"<p>Applied automatically on page load:</p> <pre><code><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</code></pre>"},{"location":"v2/features/pages/page-builder/#video-embedding","title":"Video Embedding","text":"<p>Editor Placeholder:</p> <pre><code><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</code></pre> <p>Runtime Hydration:</p> <ol> <li><code>LandingPage.tsx</code> mounts \u2192 Scans for <code>.video-block</code> elements</li> <li>Reads <code>data-*</code> attributes</li> <li>Creates React root for each block</li> <li>Renders <code>AdvancedVideoPlayer</code> or <code>VideoPlayer</code> component</li> <li>Replaces placeholder with live player</li> </ol> <p>Supported Attributes:</p> <ul> <li><code>data-video-id</code> (required) \u2014 Media library video ID</li> <li><code>data-player-type</code> (<code>\"standard\"</code> or <code>\"advanced\"</code>, default: <code>\"standard\"</code>)</li> <li><code>data-width</code> (CSS value, default: <code>\"100%\"</code>)</li> <li><code>data-height</code> (CSS value, default: <code>\"auto\"</code>)</li> <li><code>data-autoplay</code> (<code>\"true\"</code> or <code>\"false\"</code>, default: <code>\"false\"</code>)</li> <li><code>data-controls</code> (<code>\"true\"</code> or <code>\"false\"</code>, default: <code>\"true\"</code>)</li> <li><code>data-show-reactions</code> (<code>\"true\"</code> or <code>\"false\"</code>, default: <code>\"true\"</code>, advanced player only)</li> </ul>"},{"location":"v2/features/pages/page-builder/#code-examples","title":"Code Examples","text":""},{"location":"v2/features/pages/page-builder/#creating-a-page-typescript","title":"Creating a Page (TypeScript)","text":"<pre><code>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</code></pre>"},{"location":"v2/features/pages/page-builder/#saving-editor-state-grapesjs","title":"Saving Editor State (GrapesJS)","text":"<pre><code>// 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</code></pre>"},{"location":"v2/features/pages/page-builder/#fetching-published-page-public-route","title":"Fetching Published Page (Public Route)","text":"<pre><code>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</code></pre>"},{"location":"v2/features/pages/page-builder/#mkdocs-export-logic-backend","title":"MkDocs Export Logic (Backend)","text":"<pre><code>// 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</code></pre>"},{"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":"<p>Symptoms:</p> <ul> <li>Blank screen in editor</li> <li>Console error: <code>Cannot read property 'init' of undefined</code></li> </ul> <p>Causes:</p> <ul> <li>GrapesJS package not installed</li> <li>CSS import missing</li> <li>Plugin incompatibility</li> </ul> <p>Solutions:</p> <ol> <li> <p>Verify installation: <pre><code>cd admin && npm list grapesjs\n# Should show: grapesjs@0.21.x\n</code></pre></p> </li> <li> <p>Check CSS import: <pre><code>// In GrapesJSEditor.tsx\nimport 'grapesjs/dist/css/grapes.min.css';\n</code></pre></p> </li> <li> <p>Check browser console:</p> </li> <li>Look for <code>grapesjs</code> variable in global scope</li> <li> <p>Verify all plugins loaded successfully</p> </li> <li> <p>Clear cache: <pre><code># In browser DevTools\n# Right-click Reload \u2192 Empty Cache and Hard Reload\n</code></pre></p> </li> </ol>"},{"location":"v2/features/pages/page-builder/#problem-published-page-not-rendering","title":"Problem: Published Page Not Rendering","text":"<p>Symptoms:</p> <ul> <li>404 error at <code>/p/my-page</code></li> <li>Page exists in database, <code>published=true</code></li> </ul> <p>Causes:</p> <ul> <li>React route not registered</li> <li>Slug mismatch</li> <li>Public route mounted incorrectly</li> </ul> <p>Solutions:</p> <ol> <li> <p>Verify route registration: <pre><code>// In admin/src/App.tsx\n<Route path=\"/p/:slug\" element={<LandingPage />} />\n</code></pre></p> </li> <li> <p>Check slug in URL:</p> </li> <li>Slug is case-sensitive: <code>/p/About-Us</code> \u2260 <code>/p/about-us</code></li> <li> <p>Use lowercase, hyphenated: <code>/p/about-us</code></p> </li> <li> <p>Test API directly: <pre><code>curl http://localhost:4000/api/pages/about-us/view\n# Should return JSON, not 404\n</code></pre></p> </li> <li> <p>Check published status: <pre><code>SELECT slug, published FROM landing_pages WHERE slug = 'about-us';\n-- published should be true\n</code></pre></p> </li> </ol>"},{"location":"v2/features/pages/page-builder/#problem-mobile-warning-shows-on-desktop","title":"Problem: Mobile Warning Shows on Desktop","text":"<p>Symptoms:</p> <ul> <li>\"Desktop Required\" warning displays on 1920px screen</li> <li>Editor won't load</li> </ul> <p>Causes:</p> <ul> <li>Browser window width < 768px</li> <li>Breakpoint detection failure</li> <li>DevTools docked (reduces viewport width)</li> </ul> <p>Solutions:</p> <ol> <li> <p>Check actual viewport width: <pre><code>// In browser console\nconsole.log(window.innerWidth);\n// Should be > 768 for desktop\n</code></pre></p> </li> <li> <p>Undock DevTools:</p> </li> <li>Press F12 \u2192 Click \u22ee (three dots) \u2192 Dock to right/bottom \u2192 Undock</li> <li> <p>Increases available viewport width</p> </li> <li> <p>Verify breakpoint hook: <pre><code>// In PageEditorPage.tsx\nconst screens = Grid.useBreakpoint();\nconst isMobile = !screens.md; // md = 768px\n</code></pre></p> </li> <li> <p>Test responsive mode:</p> </li> <li>F12 \u2192 Toggle device toolbar (Ctrl+Shift+M)</li> <li>Select \"Responsive\" \u2192 Set width to 1024px</li> </ol>"},{"location":"v2/features/pages/page-builder/#problem-mkdocs-export-not-found","title":"Problem: MkDocs Export Not Found","text":"<p>Symptoms:</p> <ul> <li>MkDocs site shows 404 for <code>/pages/about-us/</code></li> <li>Override file missing from <code>mkdocs/docs/overrides/</code></li> </ul> <p>Causes:</p> <ul> <li>Page not published</li> <li><code>mkdocsSkipExport=true</code></li> <li>Export path incorrect</li> <li>MkDocs not rebuilt</li> </ul> <p>Solutions:</p> <ol> <li> <p>Verify publish status: <pre><code>SELECT slug, published, mkdocs_skip_export FROM landing_pages WHERE slug = 'about-us';\n-- Both should be true/false appropriately\n</code></pre></p> </li> <li> <p>Check export path: <pre><code>ls -la mkdocs/docs/overrides/about.html\n# Should exist if published and not skipped\n</code></pre></p> </li> <li> <p>Validate exports:</p> </li> <li>Admin \u2192 Pages \u2192 \"Validate Exports\" button</li> <li> <p>Check repair count</p> </li> <li> <p>Rebuild MkDocs: <pre><code>docker compose exec mkdocs mkdocs build\n# Or in admin: Pages \u2192 \"Build Site\"\n</code></pre></p> </li> <li> <p>Check template path in stub: <pre><code>cat mkdocs/docs/about.md\n# Should show: template: about.html (NOT overrides/about.html)\n</code></pre></p> </li> </ol>"},{"location":"v2/features/pages/page-builder/#problem-slug-collision-on-create","title":"Problem: Slug Collision on Create","text":"<p>Symptoms:</p> <ul> <li>Create page with title \"About Us\" \u2192 slug becomes <code>about-us-2</code></li> <li>Expected <code>about-us</code> but already taken</li> </ul> <p>Causes:</p> <ul> <li>Existing page with same slug (possibly unpublished)</li> <li>Soft-deleted page (if soft delete implemented)</li> </ul> <p>Solutions:</p> <ol> <li> <p>Check existing pages: <pre><code>SELECT id, title, slug, published FROM landing_pages WHERE slug LIKE 'about-us%';\n</code></pre></p> </li> <li> <p>Delete duplicate:</p> </li> <li>If old page is unwanted: Admin \u2192 Pages \u2192 Delete</li> <li> <p>New page can reuse slug</p> </li> <li> <p>Use unique title:</p> </li> <li> <p>Rename new page: \"About Us 2026\" \u2192 slug <code>about-us-2026</code></p> </li> <li> <p>Manual slug override:</p> </li> <li>After create: Edit page \u2192 Settings \u2192 Override Path \u2192 <code>about-us-custom.html</code></li> </ol>"},{"location":"v2/features/pages/page-builder/#problem-video-block-not-hydrating","title":"Problem: Video Block Not Hydrating","text":"<p>Symptoms:</p> <ul> <li>Video placeholder shows on published page</li> <li>No player renders</li> <li>Console error: <code>Invalid video ID: PLACEHOLDER</code></li> </ul> <p>Causes:</p> <ul> <li><code>data-video-id=\"PLACEHOLDER\"</code> not replaced</li> <li>Video ID not numeric</li> <li>Hydration script not running</li> </ul> <p>Solutions:</p> <ol> <li>Check video ID in editor:</li> <li>Open GrapesJS editor \u2192 Select video block</li> <li>Properties panel \u2192 Video ID field should be numeric (e.g., <code>123</code>)</li> <li> <p>Not <code>PLACEHOLDER</code></p> </li> <li> <p>Verify HTML output: <pre><code><!-- 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</code></pre></p> </li> <li> <p>Check hydration script: <pre><code>// 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</code></pre></p> </li> <li> <p>Test video ID validity: <pre><code>curl http://localhost:4100/api/media/videos/42\n# Should return video metadata, not 404\n</code></pre></p> </li> </ol>"},{"location":"v2/features/pages/page-builder/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/features/pages/page-builder/#editor-initialization","title":"Editor Initialization","text":"<p>GrapesJS startup: ~500ms on modern desktop</p> <p>Optimization strategies:</p> <ul> <li>Lazy load GrapesJS: <code>const GrapesJS = lazy(() => import('./GrapesJSEditor'))</code></li> <li>Show loading spinner during init</li> <li>Preload on hover over \"Edit\" button</li> </ul>"},{"location":"v2/features/pages/page-builder/#large-pages","title":"Large Pages","text":"<p>Complexity threshold: 100+ components</p> <p>Symptoms:</p> <ul> <li>Laggy drag-and-drop</li> <li>Slow save operations</li> <li>Canvas rendering delay</li> </ul> <p>Mitigations:</p> <ul> <li>Break into multiple pages (split hero + sections)</li> <li>Use CODE mode for complex layouts</li> <li>Minimize nested components</li> </ul>"},{"location":"v2/features/pages/page-builder/#htmloutput-storage","title":"htmlOutput Storage","text":"<p>Database overhead: <code>htmlOutput</code> can be 50KB+ for complex pages</p> <p>Considerations:</p> <ul> <li>Indexed by <code>published</code> for public queries (fast)</li> <li>Not indexed by content (no full-text search on HTML)</li> <li>Consider external storage for very large pages (future enhancement)</li> </ul>"},{"location":"v2/features/pages/page-builder/#public-page-rendering","title":"Public Page Rendering","text":"<p>React hydration: Video blocks hydrate after initial render (~100ms delay)</p> <p>Performance tips:</p> <ul> <li>Use <code>dangerouslySetInnerHTML</code> for immediate HTML paint</li> <li>Defer video hydration to <code>setTimeout(..., 100)</code></li> <li>Preload video metadata for above-fold players</li> </ul>"},{"location":"v2/features/pages/page-builder/#security-considerations","title":"Security Considerations","text":""},{"location":"v2/features/pages/page-builder/#admin-authored-html","title":"Admin-Authored HTML","text":"<p>Risk: XSS via malicious HTML in editor</p> <p>Mitigation:</p> <ul> <li>Accepted risk: Only admins can create/edit pages (trusted users)</li> <li>No user-supplied content: Public users cannot edit landing pages</li> <li>Authentication required: All write endpoints require admin role</li> </ul> <p>Comment in code:</p> <pre><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</code></pre>"},{"location":"v2/features/pages/page-builder/#slug-validation","title":"Slug Validation","text":"<p>Attack vector: Path traversal via slug injection</p> <p>Protection:</p> <pre><code>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</code></pre> <p>Safe slugs: <code>about-us</code>, <code>campaign-2026</code>, <code>contact</code></p> <p>Rejected: <code>../etc/passwd</code>, <code><script>alert(1)</script></code>, <code>../../admin</code></p>"},{"location":"v2/features/pages/page-builder/#mkdocs-path-validation","title":"MkDocs Path Validation","text":"<p>Attack vector: Write arbitrary files via path traversal in <code>mkdocsPath</code></p> <p>Protection:</p> <pre><code>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</code></pre> <p>Safe paths: <code>about.html</code>, <code>pages/contact.html</code></p> <p>Rejected: <code>../../../etc/passwd.html</code>, <code>/etc/shadow.html</code>, <code>%2e%2e/admin.html</code></p>"},{"location":"v2/features/pages/page-builder/#published-flag-enforcement","title":"Published Flag Enforcement","text":"<p>Attack vector: Access draft pages via public route</p> <p>Protection:</p> <pre><code>// In pagesService.findBySlugPublic()\nif (!page || !page.published) {\n throw new AppError(404, 'Page not found', 'PAGE_NOT_FOUND');\n}\n</code></pre> <p>Behavior:</p> <ul> <li>Unpublished pages return 404 on public route</li> <li>Admin routes bypass check (can view drafts)</li> </ul>"},{"location":"v2/features/pages/page-builder/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/features/pages/page-builder/#frontend-components","title":"Frontend Components","text":"<ul> <li>LandingPageEditor \u2014 Full-screen editor wrapper</li> <li>LandingPagesPage \u2014 Table view + CRUD</li> <li>GrapesJSEditor \u2014 GrapesJS wrapper with forwardRef</li> <li>PublicLandingPage \u2014 Public page renderer</li> </ul>"},{"location":"v2/features/pages/page-builder/#backend-modules","title":"Backend Modules","text":"<ul> <li>pages-admin.routes \u2014 Admin CRUD endpoints</li> <li>pages-public.routes \u2014 Public view endpoint</li> <li>pages.service \u2014 Business logic + MkDocs export</li> <li>pages.schemas \u2014 Zod validation schemas</li> </ul>"},{"location":"v2/features/pages/page-builder/#database","title":"Database","text":"<ul> <li>LandingPage Model \u2014 Schema + relationships</li> <li>PageBlock Model \u2014 Block library schema</li> </ul>"},{"location":"v2/features/pages/page-builder/#feature-documentation","title":"Feature Documentation","text":"<ul> <li>GrapesJS Editor Integration \u2014 forwardRef pattern + custom blocks</li> <li>Block Library \u2014 Reusable components system</li> <li>MkDocs Export \u2014 Material theme integration</li> </ul>"},{"location":"v2/features/pages/page-builder/#external-resources","title":"External Resources","text":"<ul> <li>GrapesJS Documentation \u2014 Official editor docs</li> <li>GrapesJS Plugins \u2014 Available plugins</li> <li>MkDocs Material \u2014 Theme docs</li> <li>Jinja2 Templates \u2014 Template syntax</li> </ul>"},{"location":"v2/features/tunnel/","title":"Tunnel Management (Pangolin)","text":"<p>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.</p>"},{"location":"v2/features/tunnel/#overview","title":"Overview","text":"<p>Pangolin integration provides:</p> <ul> <li>Secure Tunneling - Expose localhost to public internet</li> <li>Newt Container - Self-hosted exit node</li> <li>Setup Wizard - Guided configuration</li> <li>Resource Management - Subdomain and route configuration</li> <li>Status Monitoring - Tunnel health and uptime</li> <li>No DNS Configuration - Automatic subdomain setup</li> </ul>"},{"location":"v2/features/tunnel/#features","title":"Features","text":""},{"location":"v2/features/tunnel/#tunnel-setup","title":"Tunnel Setup","text":"<ul> <li>Create Pangolin organization and site</li> <li>Generate Newt container credentials</li> <li>Configure resources (subdomains)</li> <li>Deploy Newt container</li> <li>Start tunnel automatically</li> </ul>"},{"location":"v2/features/tunnel/#resource-configuration","title":"Resource Configuration","text":"<p>Map internal services to public subdomains:</p> <ul> <li><code>app.yoursite.com</code> \u2192 Admin GUI (port 3000)</li> <li><code>api.yoursite.com</code> \u2192 Express API (port 4000)</li> <li><code>media.yoursite.com</code> \u2192 Media API (port 4100)</li> <li><code>docs.yoursite.com</code> \u2192 MkDocs (port 4003)</li> <li><code>grafana.yoursite.com</code> \u2192 Grafana (port 3001)</li> <li>Custom subdomains for other services</li> </ul>"},{"location":"v2/features/tunnel/#admin-interface","title":"Admin Interface","text":"<p>Setup wizard (<code>/app/services/pangolin</code>):</p> <ol> <li>Connection - Enter Pangolin API credentials</li> <li>Organization - Create/select organization</li> <li>Site - Create/configure site</li> <li>Resources - Map services to subdomains</li> <li>Deploy - Start Newt container</li> <li>Verify - Test tunnel connectivity</li> </ol>"},{"location":"v2/features/tunnel/#status-monitoring","title":"Status Monitoring","text":"<ul> <li>Tunnel status (active/inactive)</li> <li>Resource health checks</li> <li>Traffic statistics</li> <li>Error logs</li> <li>Quick actions (restart, update config)</li> </ul>"},{"location":"v2/features/tunnel/#architecture","title":"Architecture","text":""},{"location":"v2/features/tunnel/#backend-components","title":"Backend Components","text":"<p>Pangolin Client: - <code>api/src/services/pangolin.client.ts</code> - Typed HTTP client - API key authentication - Full Integration API coverage</p> <p>Pangolin Module: - <code>api/src/modules/pangolin/pangolin.routes.ts</code> - Admin endpoints - Setup, config, status routes</p> <p>Newt Container: - Docker service in <code>docker-compose.yml</code> - Self-hosted exit node - Routes through nginx - Automatic startup</p>"},{"location":"v2/features/tunnel/#frontend-components","title":"Frontend Components","text":"<p>Admin Page: - <code>admin/src/pages/PangolinPage.tsx</code> - Setup wizard - Step-by-step configuration - Status dashboard - Resource table</p>"},{"location":"v2/features/tunnel/#docker-integration","title":"Docker Integration","text":"<p>Newt container in <code>docker-compose.yml</code>:</p> <pre><code>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</code></pre>"},{"location":"v2/features/tunnel/#configuration","title":"Configuration","text":""},{"location":"v2/features/tunnel/#environment-variables","title":"Environment Variables","text":"<pre><code># 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</code></pre>"},{"location":"v2/features/tunnel/#setup-process","title":"Setup Process","text":"<ol> <li>Create Account - Sign up at pangolin.bnkserve.org</li> <li>Get API Key - Generate API key in dashboard</li> <li>Add to .env - Set <code>PANGOLIN_API_KEY</code></li> <li>Run Wizard - Complete setup wizard in admin</li> <li>Deploy Newt - Start Newt container</li> <li>Test Tunnel - Verify public access</li> </ol>"},{"location":"v2/features/tunnel/#pangolin-api-integration","title":"Pangolin API Integration","text":""},{"location":"v2/features/tunnel/#api-client-usage","title":"API Client Usage","text":"<pre><code>import { pangolinClient } from '../services/pangolin.client';\n\n// Get organization\nconst org = await pangolinClient.getOrganization(orgId);\n\n// Create site\nconst site = await pangolinClient.createSite(orgId, {\n name: 'My Campaign Site',\n domain: 'campaign.example.com',\n});\n\n// Create resource (subdomain)\nconst resource = await pangolinClient.createResource(siteId, {\n subdomain: 'app',\n targetUrl: 'http://nginx:80',\n port: 80,\n});\n\n// Get tunnel status\nconst status = await pangolinClient.getTunnelStatus(siteId);\n</code></pre>"},{"location":"v2/features/tunnel/#authentication","title":"Authentication","text":"<p>Pangolin uses Bearer token authentication:</p> <pre><code>const headers = {\n 'Authorization': `Bearer ${apiKey}`,\n 'Content-Type': 'application/json',\n};\n</code></pre>"},{"location":"v2/features/tunnel/#setup-wizard","title":"Setup Wizard","text":""},{"location":"v2/features/tunnel/#step-1-connection","title":"Step 1: Connection","text":"<ul> <li>Enter Pangolin API key</li> <li>Validate credentials</li> <li>Test connection</li> </ul>"},{"location":"v2/features/tunnel/#step-2-organization","title":"Step 2: Organization","text":"<ul> <li>List existing organizations</li> <li>Create new organization</li> <li>Select organization</li> </ul>"},{"location":"v2/features/tunnel/#step-3-site","title":"Step 3: Site","text":"<ul> <li>List existing sites</li> <li>Create new site</li> <li>Configure domain</li> <li>Select site</li> </ul>"},{"location":"v2/features/tunnel/#step-4-resources","title":"Step 4: Resources","text":"<ul> <li>Add resources (subdomains)</li> <li>Map to internal services</li> <li>Configure routing</li> </ul> <p>Example resources:</p> <pre><code>app.yoursite.com \u2192 http://nginx:80 (proxies to admin:3000)\napi.yoursite.com \u2192 http://nginx:80 (proxies to api:4000)\n</code></pre>"},{"location":"v2/features/tunnel/#step-5-deploy","title":"Step 5: Deploy","text":"<ul> <li>Generate Newt credentials</li> <li>Update .env with credentials</li> <li>Restart Newt container</li> <li>Verify tunnel</li> </ul>"},{"location":"v2/features/tunnel/#step-6-verify","title":"Step 6: Verify","text":"<ul> <li>Test public URLs</li> <li>Check resource health</li> <li>View status dashboard</li> </ul>"},{"location":"v2/features/tunnel/#newt-container","title":"Newt Container","text":""},{"location":"v2/features/tunnel/#purpose","title":"Purpose","text":"<p>Newt is the exit node that:</p> <ul> <li>Establishes tunnel to Pangolin</li> <li>Receives public traffic</li> <li>Forwards to internal nginx</li> <li>Handles SSL/TLS termination</li> </ul>"},{"location":"v2/features/tunnel/#routing","title":"Routing","text":"<p>All traffic flows through nginx:</p> <pre><code>Public Request\n \u2193\nPangolin Tunnel\n \u2193\nNewt Container\n \u2193\nNginx (port 80)\n \u2193\nInternal Service (admin/api/etc.)\n</code></pre>"},{"location":"v2/features/tunnel/#configuration_1","title":"Configuration","text":"<p>Newt configured via environment variables:</p> <ul> <li><code>NEWT_ID</code> - Unique container identifier</li> <li><code>NEWT_SECRET</code> - Authentication secret</li> <li><code>PANGOLIN_ENDPOINT</code> - Tunnel endpoint URL</li> </ul>"},{"location":"v2/features/tunnel/#resource-management","title":"Resource Management","text":""},{"location":"v2/features/tunnel/#resource-types","title":"Resource Types","text":"<ul> <li>Web Apps - Admin, public pages</li> <li>APIs - Express API, Media API</li> <li>Services - Docs, Grafana, etc.</li> </ul>"},{"location":"v2/features/tunnel/#subdomain-mapping","title":"Subdomain Mapping","text":"<pre><code>interface Resource {\n subdomain: string; // 'app', 'api', 'docs'\n targetUrl: string; // 'http://nginx:80'\n port: number; // 80\n protocol: string; // 'http' or 'https'\n}\n</code></pre>"},{"location":"v2/features/tunnel/#internal-routing","title":"Internal Routing","text":"<p>Nginx routes by Host header:</p> <pre><code>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</code></pre>"},{"location":"v2/features/tunnel/#status-dashboard","title":"Status Dashboard","text":""},{"location":"v2/features/tunnel/#tunnel-status","title":"Tunnel Status","text":"<p>Display: - Active/inactive status - Uptime duration - Last connected time - Connection errors</p>"},{"location":"v2/features/tunnel/#resource-health","title":"Resource Health","text":"<p>For each resource: - Subdomain - Target service - Health status (online/offline) - Response time - Error count</p>"},{"location":"v2/features/tunnel/#actions","title":"Actions","text":"<p>Quick actions: - Restart tunnel - Update configuration - Add/remove resources - Test connectivity - View logs</p>"},{"location":"v2/features/tunnel/#security","title":"Security","text":""},{"location":"v2/features/tunnel/#ssltls","title":"SSL/TLS","text":"<ul> <li>Pangolin handles SSL termination</li> <li>Automatic certificate management</li> <li>HTTPS enforced on public URLs</li> <li>HTTP \u2192 HTTPS redirect</li> </ul>"},{"location":"v2/features/tunnel/#authentication_1","title":"Authentication","text":"<ul> <li>API key authentication</li> <li>Newt secret for container auth</li> <li>No public credentials exposure</li> </ul>"},{"location":"v2/features/tunnel/#access-control","title":"Access Control","text":"<ul> <li>Firewall rules (optional)</li> <li>IP whitelisting (optional)</li> <li>Rate limiting via Pangolin</li> <li>DDoS protection</li> </ul>"},{"location":"v2/features/tunnel/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/features/tunnel/#connection-issues","title":"Connection Issues","text":"<ol> <li>Verify API key</li> <li>Check organization/site IDs</li> <li>Confirm Newt credentials</li> <li>Test internal nginx routing</li> <li>Check container logs</li> </ol>"},{"location":"v2/features/tunnel/#resource-not-accessible","title":"Resource Not Accessible","text":"<ol> <li>Verify resource configuration</li> <li>Test internal service</li> <li>Check nginx config</li> <li>Review Pangolin logs</li> <li>Confirm DNS propagation</li> </ol>"},{"location":"v2/features/tunnel/#newt-container-errors","title":"Newt Container Errors","text":"<ol> <li>Check environment variables</li> <li>Verify network connectivity</li> <li>Review container logs</li> <li>Restart container</li> <li>Update Newt image</li> </ol>"},{"location":"v2/features/tunnel/#api-endpoints","title":"API Endpoints","text":""},{"location":"v2/features/tunnel/#admin-endpoints","title":"Admin Endpoints","text":"<pre><code>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</code></pre>"},{"location":"v2/features/tunnel/#comparison-to-cloudflare-tunnel","title":"Comparison to Cloudflare Tunnel","text":""},{"location":"v2/features/tunnel/#advantages","title":"Advantages","text":"<ul> <li>Self-hosted exit node (Newt)</li> <li>No vendor lock-in</li> <li>Full control over routing</li> <li>Open-source alternative</li> <li>No DNS changes required</li> </ul>"},{"location":"v2/features/tunnel/#considerations","title":"Considerations","text":"<ul> <li>Requires Pangolin account</li> <li>Newt container overhead</li> <li>Manual setup process</li> <li>Smaller ecosystem</li> </ul>"},{"location":"v2/features/tunnel/#related-documentation","title":"Related Documentation","text":"<ul> <li>Pangolin Page</li> <li>Pangolin Client</li> <li>Tunneling Deployment</li> <li>Nginx Configuration</li> <li>SSL/TLS Setup</li> <li>Docker Compose</li> </ul>"},{"location":"v2/frontend/","title":"Frontend Overview","text":"<p>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.</p>"},{"location":"v2/frontend/#architecture","title":"Architecture","text":"<p>The frontend is a single-page application (SPA) built with:</p> <ul> <li>React 19 - UI framework</li> <li>Vite - Build tool and dev server</li> <li>Ant Design 5 - Component library</li> <li>Zustand - State management</li> <li>React Router 6 - Client-side routing</li> <li>Leaflet - Interactive maps</li> <li>Axios - HTTP client with auth interceptors</li> </ul>"},{"location":"v2/frontend/#application-structure","title":"Application Structure","text":"<pre><code>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</code></pre>"},{"location":"v2/frontend/#key-components","title":"Key Components","text":""},{"location":"v2/frontend/#layouts","title":"Layouts","text":"<p>Three distinct layout components for different user contexts:</p> <ul> <li>AppLayout - Admin sidebar with role-based navigation</li> <li>PublicLayout - Dark theme public pages</li> <li>VolunteerLayout - Volunteer portal with top navigation</li> </ul>"},{"location":"v2/frontend/#components","title":"Components","text":"<p>Reusable UI components organized by feature:</p> <ul> <li>Map components (Leaflet, drawing tools, markers)</li> <li>Canvass components (GPS tracking, visit recording)</li> <li>Media components (video cards, upload, gallery)</li> <li>Email template components</li> <li>Form components and utilities</li> </ul>"},{"location":"v2/frontend/#pages","title":"Pages","text":"<p>42 page components across three sections:</p> <ul> <li>Admin Pages (30) - Campaign management, location management, settings, analytics</li> <li>Public Pages (8) - Campaign views, map, shifts, media gallery</li> <li>Volunteer Pages (4) - Canvass map, assignments, activity tracking</li> </ul>"},{"location":"v2/frontend/#state-management","title":"State Management","text":""},{"location":"v2/frontend/#zustand-stores","title":"Zustand Stores","text":"<p>Auth Store (<code>stores/auth.store.ts</code>)</p> <ul> <li>User authentication state</li> <li>Token persistence (localStorage)</li> <li>Login/logout actions</li> <li>Role-based access</li> </ul> <p>Canvass Store (<code>stores/canvass.store.ts</code>)</p> <ul> <li>Active canvass session</li> <li>GPS tracking state</li> <li>Walking route</li> <li>Visit recording</li> </ul>"},{"location":"v2/frontend/#api-integration","title":"API Integration","text":"<p>Main API Client (<code>lib/api.ts</code>)</p> <ul> <li>Axios instance with auth interceptors</li> <li>Automatic token refresh on 401</li> <li>Base URL configuration</li> <li>Error handling</li> </ul> <p>Media API Client (<code>lib/media-api.ts</code>)</p> <ul> <li>Dedicated client for Fastify media API</li> <li>Separate base URL (port 4100)</li> <li>File upload support</li> </ul> <p>Public API Client (<code>lib/media-public-api.ts</code>)</p> <ul> <li>Unauthenticated client for public media</li> <li>No auth interceptors</li> </ul>"},{"location":"v2/frontend/#routing","title":"Routing","text":"<p>Routes are organized by user role and access level:</p>"},{"location":"v2/frontend/#admin-routes-app","title":"Admin Routes (<code>/app/*</code>)","text":"<p>Require authentication and admin role:</p> <pre><code><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</code></pre>"},{"location":"v2/frontend/#public-routes","title":"Public Routes","text":"<p>No authentication required:</p> <pre><code><Route path=\"/campaigns\" element={<PublicLayout />}>\n <Route index element={<CampaignsListPage />} />\n <Route path=\":id\" element={<CampaignPage />} />\n</Route>\n</code></pre>"},{"location":"v2/frontend/#volunteer-routes-volunteer","title":"Volunteer Routes (<code>/volunteer/*</code>)","text":"<p>Require authentication, any role:</p> <pre><code><Route path=\"/volunteer\" element={<VolunteerLayout />}>\n <Route path=\"assignments\" element={<VolunteerShiftsPage />} />\n <Route path=\"canvass/:cutId\" element={<VolunteerMapPage />} />\n</Route>\n</code></pre>"},{"location":"v2/frontend/#theming","title":"Theming","text":""},{"location":"v2/frontend/#admin-theme","title":"Admin Theme","text":"<p>Light theme with primary blue colors:</p> <pre><code>colorPrimary: '#1677ff'\ncolorBgBase: '#ffffff'\n</code></pre>"},{"location":"v2/frontend/#public-theme","title":"Public Theme","text":"<p>Dark theme with blue/teal accents:</p> <pre><code>colorBgBase: '#0d1b2a'\ncolorBgContainer: '#1b2838'\ncolorPrimary: '#3498db'\n</code></pre>"},{"location":"v2/frontend/#build-development","title":"Build & Development","text":""},{"location":"v2/frontend/#development-server","title":"Development Server","text":"<pre><code>cd admin && npm run dev\n# Runs on http://localhost:3000\n</code></pre>"},{"location":"v2/frontend/#production-build","title":"Production Build","text":"<pre><code>cd admin && npm run build\n# Output: admin/dist/\n</code></pre>"},{"location":"v2/frontend/#type-checking","title":"Type Checking","text":"<pre><code>cd admin && npx tsc --noEmit\n</code></pre>"},{"location":"v2/frontend/#environment-variables","title":"Environment Variables","text":"<p>Frontend uses Vite environment variables:</p> <pre><code>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</code></pre> <p>Docker deployments override these in <code>docker-compose.yml</code> to use container hostnames.</p>"},{"location":"v2/frontend/#key-features","title":"Key Features","text":""},{"location":"v2/frontend/#responsive-design","title":"Responsive Design","text":"<ul> <li>Grid breakpoints with Ant Design</li> <li>Mobile-aware components</li> <li>Desktop-only editors (GrapesJS, Email Templates)</li> </ul>"},{"location":"v2/frontend/#form-handling","title":"Form Handling","text":"<ul> <li>Ant Design Form integration</li> <li>Zod schema validation (client-side)</li> <li>Error display and validation feedback</li> </ul>"},{"location":"v2/frontend/#data-tables","title":"Data Tables","text":"<ul> <li>Pagination support</li> <li>Search and filtering</li> <li>Sorting and column configuration</li> <li>Bulk actions</li> </ul>"},{"location":"v2/frontend/#map-integration","title":"Map Integration","text":"<ul> <li>Leaflet for interactive maps</li> <li>Custom markers and overlays</li> <li>Drawing tools for polygons</li> <li>GPS tracking for canvassing</li> </ul>"},{"location":"v2/frontend/#file-uploads","title":"File Uploads","text":"<ul> <li>Drag-and-drop support</li> <li>Progress tracking</li> <li>File type validation</li> <li>Preview generation</li> </ul>"},{"location":"v2/frontend/#related-documentation","title":"Related Documentation","text":"<ul> <li>Backend Overview</li> <li>Architecture</li> <li>Development Guide</li> <li>User Guides</li> </ul>"},{"location":"v2/frontend/#quick-links","title":"Quick Links","text":"<ul> <li>App Layout</li> <li>Dashboard Page</li> <li>Campaigns Page</li> <li>Volunteer Map</li> <li>API Client Setup</li> </ul>"},{"location":"v2/frontend/components/","title":"Frontend Components","text":"<p>Reusable UI components provide common functionality across the Changemaker Lite admin interface. Components are organized by feature area and follow React best practices.</p>"},{"location":"v2/frontend/components/#component-organization","title":"Component Organization","text":"<pre><code>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</code></pre>"},{"location":"v2/frontend/components/#layout-components","title":"Layout Components","text":""},{"location":"v2/frontend/components/#applayout","title":"AppLayout","text":"<p>Admin sidebar layout with role-based navigation:</p> <ul> <li>Location: <code>components/AppLayout.tsx</code></li> <li>Features:</li> <li>Collapsible sidebar</li> <li>Role-based menu items</li> <li>User dropdown menu</li> <li>Breadcrumb navigation</li> <li>Responsive mobile drawer</li> </ul>"},{"location":"v2/frontend/components/#publiclayout","title":"PublicLayout","text":"<p>Dark theme layout for public pages:</p> <ul> <li>Location: <code>components/PublicLayout.tsx</code></li> <li>Features:</li> <li>Dark blue/teal color scheme</li> <li>Header with logo and navigation</li> <li>Footer with links</li> <li>Responsive grid breakpoints</li> </ul>"},{"location":"v2/frontend/components/#volunteerlayout","title":"VolunteerLayout","text":"<p>Top navigation layout for volunteer portal:</p> <ul> <li>Location: <code>components/VolunteerLayout.tsx</code></li> <li>Features:</li> <li>Horizontal navigation bar</li> <li>Mobile hamburger menu</li> <li>User status display</li> <li>Active route highlighting</li> </ul>"},{"location":"v2/frontend/components/#mediapubliclayout","title":"MediaPublicLayout","text":"<p>Minimal layout for public media gallery:</p> <ul> <li>Location: <code>components/MediaPublicLayout.tsx</code></li> <li>Features:</li> <li>Clean header with branding</li> <li>Full-width content area</li> <li>Dark theme consistency</li> </ul>"},{"location":"v2/frontend/components/#map-components","title":"Map Components","text":""},{"location":"v2/frontend/components/#mapcontrols","title":"MapControls","text":"<p>Floating control buttons for map interactions:</p> <ul> <li>Location: <code>components/map/MapControls.tsx</code></li> <li>Features:</li> <li>Add location mode toggle</li> <li>Move location mode toggle</li> <li>Geolocate current position</li> <li>Fullscreen toggle</li> <li>Auto-refresh toggle</li> </ul>"},{"location":"v2/frontend/components/#addlocationmode","title":"AddLocationMode","text":"<p>Click-to-add location drawing mode:</p> <ul> <li>Location: <code>components/map/AddLocationMode.tsx</code></li> <li>Features:</li> <li>Click map to add location</li> <li>Reverse geocoding</li> <li>Form modal for details</li> <li>Marker placement</li> </ul>"},{"location":"v2/frontend/components/#movelocationmode","title":"MoveLocationMode","text":"<p>Click-to-move existing locations:</p> <ul> <li>Location: <code>components/map/MoveLocationMode.tsx</code></li> <li>Features:</li> <li>Drag markers to new position</li> <li>Update coordinates</li> <li>Cancel/confirm actions</li> </ul>"},{"location":"v2/frontend/components/#cutdrawingmode","title":"CutDrawingMode","text":"<p>Polygon drawing tool for geographic cuts:</p> <ul> <li>Location: <code>components/map/CutDrawingMode.tsx</code></li> <li>Features:</li> <li>Click vertices to draw polygon</li> <li>Close polygon detection</li> <li>Vertex editing</li> <li>Save/cancel actions</li> </ul>"},{"location":"v2/frontend/components/#cutoverlays","title":"CutOverlays","text":"<p>GeoJSON polygon rendering:</p> <ul> <li>Location: <code>components/map/CutOverlays.tsx</code></li> <li>Features:</li> <li>Multi-polygon support</li> <li>Color-coded cuts</li> <li>Click to select</li> <li>Popup information</li> </ul>"},{"location":"v2/frontend/components/#cutoverlaycontrols","title":"CutOverlayControls","text":"<p>Cut visibility toggle panel:</p> <ul> <li>Location: <code>components/map/CutOverlayControls.tsx</code></li> <li>Features:</li> <li>Show/hide individual cuts</li> <li>Bulk toggle all</li> <li>Color legend</li> </ul>"},{"location":"v2/frontend/components/#cuteditormap","title":"CutEditorMap","text":"<p>Specialized map for cut editing:</p> <ul> <li>Location: <code>components/map/CutEditorMap.tsx</code></li> <li>Features:</li> <li>Drawing mode integration</li> <li>Vertex editing</li> <li>Polygon validation</li> <li>Save to database</li> </ul>"},{"location":"v2/frontend/components/#maplegend","title":"MapLegend","text":"<p>Floating legend overlay:</p> <ul> <li>Location: <code>components/map/MapLegend.tsx</code></li> <li>Features:</li> <li>Color-coded markers</li> <li>Cut legend</li> <li>Collapsible panel</li> </ul>"},{"location":"v2/frontend/components/#canvass-components","title":"Canvass Components","text":""},{"location":"v2/frontend/components/#canvassheader","title":"CanvassHeader","text":"<p>Session header with timer and status:</p> <ul> <li>Location: <code>components/canvass/CanvassHeader.tsx</code></li> <li>Features:</li> <li>Session timer</li> <li>Start/end session</li> <li>Cut information</li> <li>Visit counter</li> </ul>"},{"location":"v2/frontend/components/#sessiontimer","title":"SessionTimer","text":"<p>Elapsed time display:</p> <ul> <li>Location: <code>components/canvass/SessionTimer.tsx</code></li> <li>Features:</li> <li>Real-time countdown</li> <li>Hours:minutes:seconds format</li> <li>Auto-update</li> </ul>"},{"location":"v2/frontend/components/#canvassmarker","title":"CanvassMarker","text":"<p>Location marker with visit status:</p> <ul> <li>Location: <code>components/canvass/CanvassMarker.tsx</code></li> <li>Features:</li> <li>Color-coded by visit status</li> <li>Click to record visit</li> <li>Popup with details</li> <li>Next location highlighting</li> </ul>"},{"location":"v2/frontend/components/#canvassmarkergroup","title":"CanvassMarkerGroup","text":"<p>Optimized marker clustering:</p> <ul> <li>Location: <code>components/canvass/CanvassMarkerGroup.tsx</code></li> <li>Features:</li> <li>Performance optimization</li> <li>Batch rendering</li> <li>Click handlers</li> </ul>"},{"location":"v2/frontend/components/#walkingrouteline","title":"WalkingRouteLine","text":"<p>Polyline for walking route:</p> <ul> <li>Location: <code>components/canvass/WalkingRouteLine.tsx</code></li> <li>Features:</li> <li>Blue dashed line</li> <li>Location-to-location path</li> <li>Auto-update on visit</li> </ul>"},{"location":"v2/frontend/components/#gpstracker","title":"GPSTracker","text":"<p>GPS position tracking:</p> <ul> <li>Location: <code>components/canvass/GPSTracker.tsx</code></li> <li>Features:</li> <li>Watch position API</li> <li>Blue GPS marker</li> <li>Accuracy circle</li> <li>Auto-center map</li> </ul>"},{"location":"v2/frontend/components/#canvassbottomtoolbar","title":"CanvassBottomToolbar","text":"<p>Bottom sheet with actions:</p> <ul> <li>Location: <code>components/canvass/CanvassBottomToolbar.tsx</code></li> <li>Features:</li> <li>Expandable drawer</li> <li>Quick actions</li> <li>Visit recording</li> <li>Session controls</li> </ul>"},{"location":"v2/frontend/components/#visitrecordingform","title":"VisitRecordingForm","text":"<p>Visit outcome form:</p> <ul> <li>Location: <code>components/canvass/VisitRecordingForm.tsx</code></li> <li>Features:</li> <li>Outcome selection</li> <li>Notes input</li> <li>GPS coordinates</li> <li>Submit with validation</li> </ul>"},{"location":"v2/frontend/components/#canvasslegend","title":"CanvassLegend","text":"<p>Map legend for canvass status:</p> <ul> <li>Location: <code>components/canvass/CanvassLegend.tsx</code></li> <li>Features:</li> <li>Status color codes</li> <li>Visit outcome legend</li> <li>Collapsible panel</li> </ul>"},{"location":"v2/frontend/components/#media-components","title":"Media Components","text":""},{"location":"v2/frontend/components/#videocard","title":"VideoCard","text":"<p>Video item display card:</p> <ul> <li>Location: <code>components/media/VideoCard.tsx</code></li> <li>Features:</li> <li>Thumbnail preview</li> <li>Title and description</li> <li>Action buttons</li> <li>Selection checkbox</li> </ul>"},{"location":"v2/frontend/components/#bulkactions","title":"BulkActions","text":"<p>Batch operation toolbar:</p> <ul> <li>Location: <code>components/media/BulkActions.tsx</code></li> <li>Features:</li> <li>Select all toggle</li> <li>Delete selected</li> <li>Lock/unlock selected</li> <li>Share selected</li> </ul>"},{"location":"v2/frontend/components/#uploadvideomodal","title":"UploadVideoModal","text":"<p>Video upload interface:</p> <ul> <li>Location: <code>components/media/UploadVideoModal.tsx</code></li> <li>Features:</li> <li>Drag-and-drop upload</li> <li>Progress tracking</li> <li>Metadata form</li> <li>Single/batch upload</li> </ul>"},{"location":"v2/frontend/components/#mediagallerygrid","title":"MediaGalleryGrid","text":"<p>Responsive video grid:</p> <ul> <li>Location: <code>components/media/MediaGalleryGrid.tsx</code></li> <li>Features:</li> <li>Masonry layout</li> <li>Lazy loading</li> <li>Infinite scroll</li> <li>Filter/sort</li> </ul>"},{"location":"v2/frontend/components/#email-template-components","title":"Email Template Components","text":""},{"location":"v2/frontend/components/#templateeditor","title":"TemplateEditor","text":"<p>Email template WYSIWYG editor:</p> <ul> <li>Location: <code>components/email-templates/TemplateEditor.tsx</code></li> <li>Features:</li> <li>Rich text editing</li> <li>Variable insertion</li> <li>Preview mode</li> <li>HTML source view</li> </ul>"},{"location":"v2/frontend/components/#variableinserter","title":"VariableInserter","text":"<p>Template variable selector:</p> <ul> <li>Location: <code>components/email-templates/VariableInserter.tsx</code></li> <li>Features:</li> <li>Variable dropdown</li> <li>Click to insert</li> <li>Variable documentation</li> <li>Preview rendering</li> </ul>"},{"location":"v2/frontend/components/#observability-components","title":"Observability Components","text":""},{"location":"v2/frontend/components/#metricschart","title":"MetricsChart","text":"<p>Prometheus metrics visualization:</p> <ul> <li>Location: <code>components/observability/MetricsChart.tsx</code></li> <li>Features:</li> <li>Time-series charts</li> <li>Multiple metrics</li> <li>Auto-refresh</li> <li>Zoom controls</li> </ul>"},{"location":"v2/frontend/components/#servicehealthcard","title":"ServiceHealthCard","text":"<p>Service status display:</p> <ul> <li>Location: <code>components/observability/ServiceHealthCard.tsx</code></li> <li>Features:</li> <li>Health indicator</li> <li>Uptime display</li> <li>Quick actions</li> <li>Error messages</li> </ul>"},{"location":"v2/frontend/components/#editor-components","title":"Editor Components","text":""},{"location":"v2/frontend/components/#grapesjseditor","title":"GrapesJSEditor","text":"<p>Landing page WYSIWYG editor:</p> <ul> <li>Location: <code>components/GrapesJSEditor.tsx</code></li> <li>Features:</li> <li>GrapesJS integration</li> <li>Custom block library</li> <li>Ctrl+S save handler</li> <li>Error boundary</li> <li>Forward ref support</li> <li>Desktop-only warning</li> </ul>"},{"location":"v2/frontend/components/#related-documentation","title":"Related Documentation","text":"<ul> <li>Frontend Overview</li> <li>Layouts</li> <li>Pages</li> <li>Map Features</li> <li>Media Features</li> </ul>"},{"location":"v2/frontend/layouts/","title":"Frontend Layouts","text":"<p>Layout components provide consistent page structure and navigation across different sections of the Changemaker Lite application. Each layout serves a specific user context with appropriate theming and navigation.</p>"},{"location":"v2/frontend/layouts/#layout-components","title":"Layout Components","text":""},{"location":"v2/frontend/layouts/#applayout","title":"AppLayout","text":"<p>Admin sidebar layout for authenticated admin users.</p> <p>Location: <code>admin/src/components/AppLayout.tsx</code></p> <p>Features:</p> <ul> <li>Collapsible sidebar navigation</li> <li>Role-based menu items (SUPER_ADMIN, INFLUENCE_ADMIN, MAP_ADMIN)</li> <li>User dropdown menu with logout</li> <li>Breadcrumb navigation</li> <li>Mobile responsive drawer</li> <li>Light theme</li> </ul> <p>Route Context: <code>/app/*</code></p> <p>Used By: - Dashboard - User management - Campaign management - Location management - Settings pages - All admin features</p> <p>Navigation Sections:</p> <ol> <li>Dashboard - Overview and quick actions</li> <li>Influence - Campaign management, responses, email queue</li> <li>Map - Locations, cuts, shifts, canvassing</li> <li>Content - Landing pages, email templates</li> <li>Media - Video library, public gallery, jobs</li> <li>Services - Integrations (Listmonk, Pangolin, MkDocs, etc.)</li> <li>System - Users, settings, observability</li> </ol> <p>Sidebar Behavior:</p> <ul> <li>Default collapsed state for better content space</li> <li>Expand on hover (desktop)</li> <li>Drawer for mobile devices</li> <li>Persistent state in localStorage</li> <li>Active menu item highlighting</li> </ul>"},{"location":"v2/frontend/layouts/#publiclayout","title":"PublicLayout","text":"<p>Dark theme layout for public-facing pages.</p> <p>Location: <code>admin/src/components/PublicLayout.tsx</code></p> <p>Features:</p> <ul> <li>Dark blue/teal color scheme (<code>#0d1b2a</code> background)</li> <li>Header with logo and navigation links</li> <li>Footer with contact/about links</li> <li>Full-width content area</li> <li>Responsive grid breakpoints</li> <li>No authentication required</li> </ul> <p>Route Context: <code>/campaigns</code>, <code>/map</code>, <code>/shifts</code>, <code>/p/:slug</code>, <code>/media</code></p> <p>Used By: - Public campaign listing - Campaign detail pages - Response wall - Public map view - Public shift signup - Landing pages - Public media gallery</p> <p>Theme Colors:</p> <pre><code>colorBgBase: '#0d1b2a' // Dark navy background\ncolorBgContainer: '#1b2838' // Container background\ncolorPrimary: '#3498db' // Bright blue\ncolorLink: '#3498db' // Link color\ncolorText: '#e0e0e0' // Light text\n</code></pre> <p>Header Navigation:</p> <ul> <li>Home link</li> <li>Campaigns</li> <li>Map</li> <li>Shifts</li> <li>Media Gallery</li> <li>Login button (when not authenticated)</li> </ul>"},{"location":"v2/frontend/layouts/#volunteerlayout","title":"VolunteerLayout","text":"<p>Top navigation layout for volunteer portal.</p> <p>Location: <code>admin/src/components/VolunteerLayout.tsx</code></p> <p>Features:</p> <ul> <li>Horizontal navigation bar</li> <li>Mobile hamburger menu</li> <li>User status display (name, role)</li> <li>Active route highlighting</li> <li>Dark theme (consistent with public)</li> <li>Logout button</li> </ul> <p>Route Context: <code>/volunteer/*</code></p> <p>Used By: - Volunteer dashboard - Shift assignments - Canvass map (linked from assignments) - Activity history - Route history</p> <p>Navigation Items:</p> <ol> <li>Dashboard - Overview and stats</li> <li>Assignments - Assigned shifts</li> <li>Activity - Visit history</li> <li>Routes - Walking route history</li> </ol> <p>Mobile Behavior:</p> <ul> <li>Hamburger menu for small screens</li> <li>Drawer navigation</li> <li>Full-width content</li> <li>Touch-friendly controls</li> </ul>"},{"location":"v2/frontend/layouts/#mediapubliclayout","title":"MediaPublicLayout","text":"<p>Minimal layout for public media gallery.</p> <p>Location: <code>admin/src/components/MediaPublicLayout.tsx</code></p> <p>Features:</p> <ul> <li>Clean header with branding</li> <li>Full-width content area</li> <li>Dark theme consistency</li> <li>No footer clutter</li> <li>Focus on media content</li> </ul> <p>Route Context: <code>/media</code>, <code>/media/:id</code></p> <p>Used By: - Public media gallery page - Video viewer page</p>"},{"location":"v2/frontend/layouts/#layout-selection-pattern","title":"Layout Selection Pattern","text":"<p>Layouts are selected based on route context:</p> <pre><code>// 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</code></pre>"},{"location":"v2/frontend/layouts/#full-screen-pages","title":"Full-Screen Pages","text":"<p>Some pages render without any layout wrapper:</p> <ul> <li>VolunteerMapPage - Full-screen canvass map with GPS</li> <li>PageEditorPage - GrapesJS editor (desktop-only)</li> <li>EmailTemplateEditorPage - Email template editor</li> </ul> <p>These pages handle their own navigation and controls.</p>"},{"location":"v2/frontend/layouts/#layout-customization","title":"Layout Customization","text":""},{"location":"v2/frontend/layouts/#theme-overrides","title":"Theme Overrides","text":"<p>Layouts use Ant Design ConfigProvider for theming:</p> <pre><code><ConfigProvider\n theme={{\n token: {\n colorPrimary: '#3498db',\n colorBgBase: '#0d1b2a',\n // ... more tokens\n },\n }}\n>\n {children}\n</ConfigProvider>\n</code></pre>"},{"location":"v2/frontend/layouts/#role-based-navigation","title":"Role-Based Navigation","text":"<p>AppLayout filters menu items based on user role:</p> <pre><code>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</code></pre>"},{"location":"v2/frontend/layouts/#responsive-breakpoints","title":"Responsive Breakpoints","text":"<p>Layouts use Ant Design grid breakpoints:</p> <ul> <li>xs - < 576px (mobile)</li> <li>sm - \u2265 576px (tablet)</li> <li>md - \u2265 768px (small desktop)</li> <li>lg - \u2265 992px (desktop)</li> <li>xl - \u2265 1200px (large desktop)</li> <li>xxl - \u2265 1600px (extra large)</li> </ul> <p>Access via <code>Grid.useBreakpoint()</code>:</p> <pre><code>const screens = Grid.useBreakpoint();\nconst isMobile = !screens.md;\n</code></pre>"},{"location":"v2/frontend/layouts/#related-documentation","title":"Related Documentation","text":"<ul> <li>Frontend Overview</li> <li>Components</li> <li>Admin Pages</li> <li>Public Pages</li> <li>Volunteer Pages</li> <li>Theming Guide</li> </ul>"},{"location":"v2/frontend/pages/","title":"Frontend Pages","text":"<p>Page components provide the main user interface screens for Changemaker Lite. Pages are organized into three categories based on user access and context.</p>"},{"location":"v2/frontend/pages/#page-categories","title":"Page Categories","text":""},{"location":"v2/frontend/pages/#admin-pages-30-pages","title":"Admin Pages (30 pages)","text":"<p>Authenticated admin interface for campaign management, location management, settings, and system administration. Requires admin role (SUPER_ADMIN, INFLUENCE_ADMIN, or MAP_ADMIN).</p> <p>Route Prefix: <code>/app/*</code></p> <p>Layout: AppLayout (sidebar navigation)</p> <p>Key Pages: - Dashboard - User management - Campaign management - Location and mapping - Settings and configuration - Media library - Service integrations</p>"},{"location":"v2/frontend/pages/#public-pages-8-pages","title":"Public Pages (8 pages)","text":"<p>Public-facing pages accessible without authentication. Used by campaign supporters and volunteers to view campaigns, sign up for shifts, and interact with content.</p> <p>Route Prefix: Various (<code>/campaigns</code>, <code>/map</code>, <code>/shifts</code>, <code>/p/:slug</code>, <code>/media</code>)</p> <p>Layout: PublicLayout (dark theme)</p> <p>Key Pages: - Campaign listing and details - Response wall - Public map view - Shift signup - Landing pages - Media gallery</p>"},{"location":"v2/frontend/pages/#volunteer-pages-4-pages","title":"Volunteer Pages (4 pages)","text":"<p>Volunteer portal for canvassing activities. Requires authentication (any role) and provides tools for door-to-door canvassing, GPS tracking, and activity tracking.</p> <p>Route Prefix: <code>/volunteer/*</code></p> <p>Layout: VolunteerLayout (top navigation)</p> <p>Key Pages: - Volunteer dashboard - Shift assignments - Full-screen canvass map - Activity history - Route history</p>"},{"location":"v2/frontend/pages/#page-overview-by-feature","title":"Page Overview by Feature","text":""},{"location":"v2/frontend/pages/#authentication","title":"Authentication","text":"<ul> <li>LoginPage - User login with JWT authentication</li> </ul>"},{"location":"v2/frontend/pages/#dashboard-analytics","title":"Dashboard & Analytics","text":"<ul> <li>DashboardPage - Admin overview with stats and recent activity</li> <li>CanvassDashboardPage - Canvass monitoring and leaderboard</li> <li>DataQualityDashboardPage - Geocoding quality metrics</li> <li>ObservabilityPage - Prometheus/Grafana monitoring</li> </ul>"},{"location":"v2/frontend/pages/#campaign-management","title":"Campaign Management","text":"<ul> <li>CampaignsPage - Campaign CRUD table</li> <li>CampaignPage (Public) - Campaign detail with email form</li> <li>CampaignsListPage (Public) - Featured campaign listing</li> <li>ResponsesPage - Response moderation</li> <li>ResponseWallPage (Public) - Public response submissions</li> <li>RepresentativesPage - Representative cache management</li> <li>EmailQueuePage - Email queue monitoring</li> </ul>"},{"location":"v2/frontend/pages/#location-mapping","title":"Location & Mapping","text":"<ul> <li>LocationsPage - Location CRUD, CSV import/export, geocoding</li> <li>MapPage (Public) - Public Leaflet map with locations and cuts</li> <li>CutsPage - Geographic cut management with polygon drawing</li> <li>MapSettingsPage - Map configuration</li> <li>ShiftsPage - Volunteer shift management</li> <li>ShiftsPage (Public) - Public shift signup</li> </ul>"},{"location":"v2/frontend/pages/#canvassing","title":"Canvassing","text":"<ul> <li>VolunteerMapPage - Full-screen GPS canvass map</li> <li>VolunteerShiftsPage - Assigned shifts for volunteers</li> <li>MyActivityPage - Visit history and outcomes</li> <li>MyRoutesPage - Walking route history</li> <li>WalkSheetPage - Printable walk sheet with QR codes</li> <li>CutExportPage - Printable location report</li> </ul>"},{"location":"v2/frontend/pages/#content-management","title":"Content Management","text":"<ul> <li>LandingPagesPage - Landing page CRUD</li> <li>PageEditorPage - GrapesJS WYSIWYG editor</li> <li>LandingPage (Public) - Rendered landing page</li> <li>EmailTemplatesPage - Email template CRUD</li> <li>EmailTemplateEditorPage - Email template editor</li> </ul>"},{"location":"v2/frontend/pages/#media-management","title":"Media Management","text":"<ul> <li>LibraryPage - Video library management</li> <li>SharedMediaPage - Public gallery administration</li> <li>MediaJobsPage - Job queue monitoring</li> <li>MediaGalleryPage (Public) - Public video gallery</li> <li>MediaViewerPage (Public) - Video detail page</li> </ul>"},{"location":"v2/frontend/pages/#system-settings","title":"System & Settings","text":"<ul> <li>UsersPage - User CRUD with role management</li> <li>SettingsPage - Global site settings</li> </ul>"},{"location":"v2/frontend/pages/#service-integrations","title":"Service Integrations","text":"<ul> <li>ListmonkPage - Newsletter sync management</li> <li>PangolinPage - Tunnel setup wizard</li> <li>DocsPage - MkDocs export management</li> <li>MkDocsSettingsPage - Documentation configuration</li> <li>MiniQRPage - QR code service iframe</li> <li>MailHogPage - Email capture UI</li> <li>CodeEditorPage - Code Server management</li> <li>N8nPage - Workflow automation</li> <li>GiteaPage - Git repository hosting</li> <li>NocoDBPage - Data browser management</li> </ul>"},{"location":"v2/frontend/pages/#page-count-summary","title":"Page Count Summary","text":"Category Count Description Admin 30 Admin interface pages Public 8 Public-facing pages Volunteer 4 Volunteer portal pages Total 42 All page components"},{"location":"v2/frontend/pages/#common-page-patterns","title":"Common Page Patterns","text":""},{"location":"v2/frontend/pages/#data-tables","title":"Data Tables","text":"<p>Most CRUD pages use Ant Design Table with:</p> <ul> <li>Pagination (server-side)</li> <li>Search and filtering</li> <li>Sorting</li> <li>Action buttons (edit, delete)</li> <li>Bulk operations</li> <li>Export options</li> </ul>"},{"location":"v2/frontend/pages/#forms","title":"Forms","text":"<p>Form pages use Ant Design Form with:</p> <ul> <li>Zod schema validation</li> <li>Error display</li> <li>Submit handlers</li> <li>Cancel/reset actions</li> <li>Auto-save (where applicable)</li> </ul>"},{"location":"v2/frontend/pages/#maps","title":"Maps","text":"<p>Map pages use React Leaflet with:</p> <ul> <li>Tile layers (OpenStreetMap)</li> <li>Markers and overlays</li> <li>Drawing tools</li> <li>Geolocate controls</li> <li>Fullscreen mode</li> </ul>"},{"location":"v2/frontend/pages/#mobile-responsiveness","title":"Mobile Responsiveness","text":"<p>Pages use responsive design patterns:</p> <ul> <li>Grid breakpoints with <code>Grid.useBreakpoint()</code></li> <li>Mobile-specific layouts</li> <li>Touch-friendly controls</li> <li>Responsive tables</li> <li>Desktop-only warnings (for editors)</li> </ul>"},{"location":"v2/frontend/pages/#route-protection","title":"Route Protection","text":"<p>Pages are protected based on authentication and role:</p> <pre><code>// 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</code></pre>"},{"location":"v2/frontend/pages/#related-documentation","title":"Related Documentation","text":"<ul> <li>Frontend Overview</li> <li>Layouts</li> <li>Components</li> <li>Backend Modules</li> <li>User Guides</li> </ul>"},{"location":"v2/frontend/pages/admin/","title":"Admin Pages","text":"<p>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.</p>"},{"location":"v2/frontend/pages/admin/#route-context","title":"Route Context","text":"<ul> <li>Prefix: <code>/app/*</code></li> <li>Layout: AppLayout (sidebar navigation)</li> <li>Auth Required: Yes</li> <li>Roles: SUPER_ADMIN, INFLUENCE_ADMIN, MAP_ADMIN (feature-specific)</li> </ul>"},{"location":"v2/frontend/pages/admin/#dashboard-overview","title":"Dashboard & Overview","text":""},{"location":"v2/frontend/pages/admin/#dashboard-page","title":"Dashboard Page","text":"<p>Route: <code>/app/dashboard</code></p> <p>Main admin landing page with:</p> <ul> <li>Recent activity feed</li> <li>Quick statistics (users, campaigns, locations)</li> <li>Action shortcuts</li> <li>System status overview</li> </ul> <p>Role: Any admin role</p>"},{"location":"v2/frontend/pages/admin/#user-management","title":"User Management","text":""},{"location":"v2/frontend/pages/admin/#users-page","title":"Users Page","text":"<p>Route: <code>/app/users</code></p> <p>User CRUD interface with:</p> <ul> <li>Paginated user table</li> <li>Search and filter</li> <li>Role assignment</li> <li>User creation/editing</li> <li>Bulk operations</li> </ul> <p>Role: SUPER_ADMIN only</p>"},{"location":"v2/frontend/pages/admin/#settings-page","title":"Settings Page","text":"<p>Route: <code>/app/settings</code></p> <p>Global site settings:</p> <ul> <li>Site name and branding</li> <li>Contact information</li> <li>Feature flags</li> <li>API configuration</li> </ul> <p>Role: SUPER_ADMIN only</p>"},{"location":"v2/frontend/pages/admin/#influence-module","title":"Influence Module","text":""},{"location":"v2/frontend/pages/admin/#campaigns-page","title":"Campaigns Page","text":"<p>Route: <code>/app/influence/campaigns</code></p> <p>Campaign management:</p> <ul> <li>Campaign CRUD table</li> <li>Status filtering</li> <li>Email stats drawer</li> <li>Target audience configuration</li> </ul> <p>Role: SUPER_ADMIN, INFLUENCE_ADMIN</p>"},{"location":"v2/frontend/pages/admin/#responses-page","title":"Responses Page","text":"<p>Route: <code>/app/influence/responses</code></p> <p>Response moderation:</p> <ul> <li>Response table with filters</li> <li>Verification status</li> <li>Detail drawer</li> <li>Bulk moderation</li> <li>Export options</li> </ul> <p>Role: SUPER_ADMIN, INFLUENCE_ADMIN</p>"},{"location":"v2/frontend/pages/admin/#representatives-page","title":"Representatives Page","text":"<p>Route: <code>/app/influence/representatives</code></p> <p>Representative cache:</p> <ul> <li>Lookup by postal code</li> <li>Cache statistics</li> <li>Manual cache refresh</li> <li>Representative details</li> </ul> <p>Role: SUPER_ADMIN, INFLUENCE_ADMIN</p>"},{"location":"v2/frontend/pages/admin/#email-queue-page","title":"Email Queue Page","text":"<p>Route: <code>/app/influence/email-queue</code></p> <p>BullMQ queue monitoring:</p> <ul> <li>Queue statistics</li> <li>Job status (active, completed, failed)</li> <li>Pause/resume controls</li> <li>Failed job retry</li> <li>Queue cleanup</li> </ul> <p>Role: SUPER_ADMIN, INFLUENCE_ADMIN</p>"},{"location":"v2/frontend/pages/admin/#map-module","title":"Map Module","text":""},{"location":"v2/frontend/pages/admin/#locations-page","title":"Locations Page","text":"<p>Route: <code>/app/map/locations</code></p> <p>Location database management:</p> <ul> <li>Location CRUD table</li> <li>CSV import/export</li> <li>Geocoding controls</li> <li>Map integration</li> <li>NAR import</li> <li>Bulk operations</li> </ul> <p>Role: SUPER_ADMIN, MAP_ADMIN</p>"},{"location":"v2/frontend/pages/admin/#cuts-page","title":"Cuts Page","text":"<p>Route: <code>/app/map/cuts</code></p> <p>Geographic cut management:</p> <ul> <li>Cut CRUD table</li> <li>Map drawing interface</li> <li>Polygon editing</li> <li>Point-in-polygon queries</li> <li>Export options</li> </ul> <p>Role: SUPER_ADMIN, MAP_ADMIN</p>"},{"location":"v2/frontend/pages/admin/#shifts-page","title":"Shifts Page","text":"<p>Route: <code>/app/map/shifts</code></p> <p>Volunteer shift management:</p> <ul> <li>Shift CRUD table</li> <li>Signups drawer</li> <li>Email all volunteers</li> <li>Cut assignment</li> <li>Status tracking</li> </ul> <p>Role: SUPER_ADMIN, MAP_ADMIN</p>"},{"location":"v2/frontend/pages/admin/#map-settings-page","title":"Map Settings Page","text":"<p>Route: <code>/app/map/settings</code></p> <p>Map configuration:</p> <ul> <li>Default center coordinates</li> <li>Default zoom level</li> <li>Walk sheet settings</li> <li>Display preferences</li> </ul> <p>Role: SUPER_ADMIN, MAP_ADMIN</p>"},{"location":"v2/frontend/pages/admin/#data-quality-dashboard-page","title":"Data Quality Dashboard Page","text":"<p>Route: <code>/app/map/data-quality</code></p> <p>Geocoding quality metrics:</p> <ul> <li>Geocode success rates by provider</li> <li>Failed geocode list</li> <li>Provider statistics</li> <li>Retry controls</li> </ul> <p>Role: SUPER_ADMIN, MAP_ADMIN</p>"},{"location":"v2/frontend/pages/admin/#canvassing","title":"Canvassing","text":""},{"location":"v2/frontend/pages/admin/#canvass-dashboard-page","title":"Canvass Dashboard Page","text":"<p>Route: <code>/app/canvass/dashboard</code></p> <p>Canvass monitoring:</p> <ul> <li>Active session tracking</li> <li>Visit statistics</li> <li>Cut progress</li> <li>Volunteer leaderboard</li> <li>Activity feed</li> </ul> <p>Role: SUPER_ADMIN, MAP_ADMIN</p>"},{"location":"v2/frontend/pages/admin/#walk-sheet-page","title":"Walk Sheet Page","text":"<p>Route: <code>/app/canvass/walk-sheet</code></p> <p>Printable walk sheet:</p> <ul> <li>Location list by cut</li> <li>QR codes for quick access</li> <li>Walking route order</li> <li>Browser print integration</li> </ul> <p>Role: SUPER_ADMIN, MAP_ADMIN</p>"},{"location":"v2/frontend/pages/admin/#cut-export-page","title":"Cut Export Page","text":"<p>Route: <code>/app/canvass/cut-export</code></p> <p>Printable location report:</p> <ul> <li>Cut statistics</li> <li>Location table</li> <li>Map snapshot</li> <li>Browser print integration</li> </ul> <p>Role: SUPER_ADMIN, MAP_ADMIN</p>"},{"location":"v2/frontend/pages/admin/#content-management","title":"Content Management","text":""},{"location":"v2/frontend/pages/admin/#landing-pages-page","title":"Landing Pages Page","text":"<p>Route: <code>/app/pages</code></p> <p>Landing page CRUD:</p> <ul> <li>Page table with search</li> <li>Create/edit/delete</li> <li>Slug management</li> <li>Settings modal</li> <li>MkDocs export</li> </ul> <p>Role: SUPER_ADMIN</p>"},{"location":"v2/frontend/pages/admin/#page-editor-page","title":"Page Editor Page","text":"<p>Route: <code>/app/pages/:id/edit</code></p> <p>GrapesJS WYSIWYG editor:</p> <ul> <li>Full-screen editor</li> <li>Custom block library</li> <li>Ctrl+S save</li> <li>Desktop-only</li> <li>Preview mode</li> </ul> <p>Role: SUPER_ADMIN</p>"},{"location":"v2/frontend/pages/admin/#email-templates-page","title":"Email Templates Page","text":"<p>Route: <code>/app/email-templates</code></p> <p>Email template CRUD:</p> <ul> <li>Template table</li> <li>Variable documentation</li> <li>Preview rendering</li> <li>Version history</li> </ul> <p>Role: SUPER_ADMIN</p>"},{"location":"v2/frontend/pages/admin/#email-template-editor-page","title":"Email Template Editor Page","text":"<p>Route: <code>/app/email-templates/:id/edit</code></p> <p>Email template editor:</p> <ul> <li>Rich text editing</li> <li>Variable insertion</li> <li>HTML source view</li> <li>Preview mode</li> <li>Desktop-only</li> </ul> <p>Role: SUPER_ADMIN</p>"},{"location":"v2/frontend/pages/admin/#media-management","title":"Media Management","text":""},{"location":"v2/frontend/pages/admin/#library-page","title":"Library Page","text":"<p>Route: <code>/app/media/library</code></p> <p>Video library management:</p> <ul> <li>Video CRUD table</li> <li>Upload modal</li> <li>Metadata editing</li> <li>Lock/unlock</li> <li>Bulk operations</li> </ul> <p>Role: SUPER_ADMIN</p>"},{"location":"v2/frontend/pages/admin/#shared-media-page","title":"Shared Media Page","text":"<p>Route: <code>/app/media/shared</code></p> <p>Public gallery administration:</p> <ul> <li>Shared video management</li> <li>Category assignment</li> <li>Visibility controls</li> <li>Reaction moderation</li> </ul> <p>Role: SUPER_ADMIN</p>"},{"location":"v2/frontend/pages/admin/#media-jobs-page","title":"Media Jobs Page","text":"<p>Route: <code>/app/media/jobs</code></p> <p>Job queue monitoring:</p> <ul> <li>Job status tracking</li> <li>Progress indicators</li> <li>Error logs</li> <li>Retry controls</li> </ul> <p>Role: SUPER_ADMIN</p>"},{"location":"v2/frontend/pages/admin/#service-integrations","title":"Service Integrations","text":""},{"location":"v2/frontend/pages/admin/#listmonk-page","title":"Listmonk Page","text":"<p>Route: <code>/app/services/listmonk</code></p> <p>Newsletter sync management:</p> <ul> <li>Connection status</li> <li>Sync controls</li> <li>List statistics</li> <li>Test connection</li> </ul> <p>Role: SUPER_ADMIN</p>"},{"location":"v2/frontend/pages/admin/#pangolin-page","title":"Pangolin Page","text":"<p>Route: <code>/app/services/pangolin</code></p> <p>Tunnel setup wizard:</p> <ul> <li>Tunnel status</li> <li>Configuration wizard</li> <li>Site management</li> <li>Resource configuration</li> </ul> <p>Role: SUPER_ADMIN</p>"},{"location":"v2/frontend/pages/admin/#docs-page","title":"Docs Page","text":"<p>Route: <code>/app/services/docs</code></p> <p>MkDocs management:</p> <ul> <li>Service status</li> <li>Export table</li> <li>Configuration</li> <li>Health checks</li> </ul> <p>Role: SUPER_ADMIN</p>"},{"location":"v2/frontend/pages/admin/#mkdocs-settings-page","title":"MkDocs Settings Page","text":"<p>Route: <code>/app/services/mkdocs-settings</code></p> <p>Documentation configuration:</p> <ul> <li>Site settings</li> <li>Export options</li> <li>Template configuration</li> </ul> <p>Role: SUPER_ADMIN</p>"},{"location":"v2/frontend/pages/admin/#mini-qr-page","title":"Mini QR Page","text":"<p>Route: <code>/app/services/qr</code></p> <p>QR code service iframe:</p> <ul> <li>QR generation interface</li> <li>Download options</li> </ul> <p>Role: SUPER_ADMIN</p>"},{"location":"v2/frontend/pages/admin/#mailhog-page","title":"MailHog Page","text":"<p>Route: <code>/app/services/mailhog</code></p> <p>Email capture UI:</p> <ul> <li>Test email viewer</li> <li>Email list</li> <li>Search/filter</li> </ul> <p>Role: SUPER_ADMIN</p>"},{"location":"v2/frontend/pages/admin/#code-editor-page","title":"Code Editor Page","text":"<p>Route: <code>/app/services/code</code></p> <p>Code Server management:</p> <ul> <li>Code editor iframe</li> <li>File browser</li> <li>Terminal access</li> </ul> <p>Role: SUPER_ADMIN</p>"},{"location":"v2/frontend/pages/admin/#n8n-page","title":"N8n Page","text":"<p>Route: <code>/app/services/n8n</code></p> <p>Workflow automation:</p> <ul> <li>n8n interface iframe</li> <li>Workflow management</li> </ul> <p>Role: SUPER_ADMIN</p>"},{"location":"v2/frontend/pages/admin/#gitea-page","title":"Gitea Page","text":"<p>Route: <code>/app/services/gitea</code></p> <p>Git repository hosting:</p> <ul> <li>Gitea interface iframe</li> <li>Repository browser</li> </ul> <p>Role: SUPER_ADMIN</p>"},{"location":"v2/frontend/pages/admin/#nocodb-page","title":"NocoDB Page","text":"<p>Route: <code>/app/services/nocodb</code></p> <p>Data browser management:</p> <ul> <li>NocoDB interface iframe</li> <li>Database browser</li> </ul> <p>Role: SUPER_ADMIN</p>"},{"location":"v2/frontend/pages/admin/#monitoring","title":"Monitoring","text":""},{"location":"v2/frontend/pages/admin/#observability-page","title":"Observability Page","text":"<p>Route: <code>/app/observability</code></p> <p>Monitoring dashboard:</p> <ul> <li>Prometheus metrics</li> <li>Grafana dashboards</li> <li>Alertmanager alerts</li> <li>Service health</li> </ul> <p>Role: SUPER_ADMIN</p>"},{"location":"v2/frontend/pages/admin/#admin-page-count","title":"Admin Page Count","text":"<p>Total: 30 admin pages</p>"},{"location":"v2/frontend/pages/admin/#common-features","title":"Common Features","text":"<p>Most admin pages include:</p> <ul> <li>Data Tables - Pagination, search, sort, filters</li> <li>Forms - Validation, error display, submit handlers</li> <li>Modals - Create/edit forms, detail views</li> <li>Drawers - Side panels for related data</li> <li>Action Buttons - CRUD operations, exports, bulk actions</li> <li>Loading States - Spinners, skeletons</li> <li>Error Handling - User-friendly error messages</li> </ul>"},{"location":"v2/frontend/pages/admin/#related-documentation","title":"Related Documentation","text":"<ul> <li>Frontend Pages Overview</li> <li>Public Pages</li> <li>Volunteer Pages</li> <li>Backend Modules</li> <li>Admin User Guide</li> </ul>"},{"location":"v2/frontend/pages/admin/campaigns-page/","title":"CampaignsPage","text":""},{"location":"v2/frontend/pages/admin/campaigns-page/#overview","title":"Overview","text":"<p>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.</p> <p>Route: <code>/app/influence/campaigns</code> Component: <code>admin/src/pages/CampaignsPage.tsx</code> (507 lines) Auth Required: Yes (SUPER_ADMIN, INFLUENCE_ADMIN roles) Layout: AppLayout</p>"},{"location":"v2/frontend/pages/admin/campaigns-page/#screenshot","title":"Screenshot","text":"<p>[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.]</p>"},{"location":"v2/frontend/pages/admin/campaigns-page/#features","title":"Features","text":"<ul> <li>Full CRUD operations \u2014 Create, read, update, delete campaigns</li> <li>Advanced search \u2014 300ms debounced search by title or description</li> <li>Status filtering \u2014 Filter by DRAFT, ACTIVE, PAUSED, ARCHIVED</li> <li>Campaign highlighting \u2014 Star icon indicates featured campaigns (highlightCampaign flag)</li> <li>Government level tags \u2014 Visual tags for FEDERAL, PROVINCIAL, MUNICIPAL, SCHOOL_BOARD</li> <li>Email statistics \u2014 Click MailOutlined icon to open emails drawer with campaign email stats</li> <li>Public link management \u2014 Copy campaign public link, view public page (ACTIVE only)</li> <li>Comprehensive feature flags \u2014 9 boolean toggles for campaign behavior:</li> <li>Allow SMTP Email (send via queue)</li> <li>Allow Mailto Link (browser email client)</li> <li>Collect User Info (name, email, postal code)</li> <li>Show Email Count (display total emails sent)</li> <li>Show Call Count (display total calls made)</li> <li>Allow Email Editing (user can edit template)</li> <li>Allow Custom Recipients (user can add custom reps)</li> <li>Show Response Wall (public response submission + display)</li> <li>Highlight Campaign (featured on public campaigns list)</li> <li>Color-coded statuses \u2014 Visual distinction between draft, active, paused, archived</li> <li>Responsive table \u2014 Columns hide on smaller screens (Gov. Levels: md+, Responses: lg+, Created: md+)</li> <li>Delete confirmation \u2014 Warns that associated emails and responses will also be deleted</li> </ul>"},{"location":"v2/frontend/pages/admin/campaigns-page/#user-workflow","title":"User Workflow","text":""},{"location":"v2/frontend/pages/admin/campaigns-page/#viewing-campaigns-list","title":"Viewing Campaigns List","text":"<ol> <li>Navigate to <code>/app/influence/campaigns</code></li> <li>Page loads first 20 campaigns (pagination)</li> <li>View campaign stats: Emails count, Responses count</li> <li>See campaign status with colored tags</li> <li>Identify featured campaigns by star icon (highlightCampaign)</li> <li>Note public URL slug below campaign title</li> </ol>"},{"location":"v2/frontend/pages/admin/campaigns-page/#creating-a-new-campaign","title":"Creating a New Campaign","text":"<ol> <li>Click \"Create Campaign\" button in page header</li> <li>Modal opens (640px width) with vertical form</li> <li>Fill required fields:</li> <li>Title (auto-generates slug from title)</li> <li>Email Subject</li> <li>Email Body (template shown to users)</li> <li>Fill optional fields:</li> <li>Description (internal note, not shown to public)</li> <li>Call to Action (additional instructions for users)</li> <li>Government Levels (multi-select: Federal, Provincial, Municipal, School Board)</li> <li>Cover Photo URL (hero image on public campaign page)</li> <li>Status (default: DRAFT)</li> <li>Configure feature flags (9 switches in 2-column grid):</li> <li>Default ON: allowSmtpEmail, allowMailtoLink, collectUserInfo, showEmailCount, showCallCount</li> <li>Default OFF: allowEmailEditing, allowCustomRecipients, showResponseWall, highlightCampaign</li> <li>Click \"Create\" button</li> <li>Success message: \"Campaign created\"</li> <li>Modal closes, table refreshes to page 1</li> <li>New campaign appears at top (most recent first)</li> </ol>"},{"location":"v2/frontend/pages/admin/campaigns-page/#editing-an-existing-campaign","title":"Editing an Existing Campaign","text":"<ol> <li>Locate campaign in table</li> <li>Click Edit icon button (EditOutlined) in Actions column</li> <li>Edit modal opens (640px width) with pre-filled values</li> <li>Modify any fields (same form as create)</li> <li>Click \"Save\" button</li> <li>Success message: \"Campaign updated\"</li> <li>Modal closes, table refreshes with updated data</li> <li>If title changed, slug auto-updates</li> </ol>"},{"location":"v2/frontend/pages/admin/campaigns-page/#viewing-campaign-emails","title":"Viewing Campaign Emails","text":"<ol> <li>Locate campaign in table</li> <li>Click Mail icon button (MailOutlined) in Actions column</li> <li>CampaignEmailsDrawer opens on right side (see CampaignEmailsDrawer)</li> <li>View email statistics:</li> <li>Total emails sent</li> <li>Delivered, failed, pending counts</li> <li>Email list with recipient, status, timestamp</li> <li>Click \"X\" to close drawer</li> </ol>"},{"location":"v2/frontend/pages/admin/campaigns-page/#publishing-a-campaign","title":"Publishing a Campaign","text":"<ol> <li>Open campaign in edit modal</li> <li>Change Status dropdown from DRAFT to ACTIVE</li> <li>Click \"Save\"</li> <li>Campaign now visible on public <code>/campaigns</code> page</li> <li>View icon button (EyeOutlined) now enabled</li> <li>Click View to open public campaign page in new tab</li> </ol>"},{"location":"v2/frontend/pages/admin/campaigns-page/#copying-public-campaign-link","title":"Copying Public Campaign Link","text":"<ol> <li>Locate ACTIVE campaign in table</li> <li>Click Link icon button (LinkOutlined) in Actions column</li> <li>URL copied to clipboard: <code>http://app.cmlite.org/campaign/{slug}</code></li> <li>Success message: \"Campaign link copied\"</li> <li>Share link with supporters</li> </ol>"},{"location":"v2/frontend/pages/admin/campaigns-page/#searching-and-filtering","title":"Searching and Filtering","text":"<ol> <li>Use search bar at top left:</li> <li>Type title or description keywords</li> <li>300ms debounce (waits for typing to stop)</li> <li>Search resets pagination to page 1</li> <li>Use status filter dropdown at top right:</li> <li>Select DRAFT, ACTIVE, PAUSED, or ARCHIVED</li> <li>Filter resets pagination to page 1</li> <li>Clear filter to show all campaigns</li> <li>Filters persist during pagination</li> </ol>"},{"location":"v2/frontend/pages/admin/campaigns-page/#deleting-a-campaign","title":"Deleting a Campaign","text":"<ol> <li>Locate campaign in table</li> <li>Click Delete icon button (DeleteOutlined) in Actions column</li> <li>Popconfirm appears: \"Delete this campaign?\"</li> <li>Description: \"All associated emails and responses will also be deleted.\"</li> <li>Click \"OK\" to confirm</li> <li>Success message: \"Campaign deleted\"</li> <li>Table refreshes</li> <li>Associated CampaignEmail and Response records also deleted (cascade)</li> </ol>"},{"location":"v2/frontend/pages/admin/campaigns-page/#component-breakdown","title":"Component Breakdown","text":""},{"location":"v2/frontend/pages/admin/campaigns-page/#ant-design-components-used","title":"Ant Design Components Used","text":"<ul> <li>Table \u2014 Main campaigns list with columns, pagination, responsive breakpoints</li> <li>Input \u2014 Search text input with SearchOutlined prefix icon</li> <li>Select \u2014 Status filter dropdown with 4 options</li> <li>Button \u2014 Create (primary), view, copy link, email stats, edit, delete actions</li> <li>Modal \u2014 Create and edit campaign forms (destroyOnHidden)</li> <li>Form \u2014 Vertical layout with all campaign fields</li> <li>Form.Item \u2014 Individual field wrappers with labels, rules, help text</li> <li>Input.TextArea \u2014 Multi-line fields (description, email body, call to action)</li> <li>Row, Col \u2014 Responsive grid for status + gov levels (2 columns), feature flags (2 columns, 9 switches)</li> <li>Switch \u2014 Boolean feature flag toggles with valuePropName=\"checked\"</li> <li>Tag \u2014 Status tags (color-coded), government level tags (color-coded)</li> <li>Space \u2014 Action button grouping</li> <li>Popconfirm \u2014 Delete confirmation with warning message</li> <li>Divider \u2014 Feature flags section separator</li> </ul>"},{"location":"v2/frontend/pages/admin/campaigns-page/#table-columns","title":"Table Columns","text":"<pre><code>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</code></pre> <p>Key patterns: - <code>_count</code> aggregation fields from Prisma (emails, responses) - Responsive column visibility with <code>responsive: ['md']</code> - Conditional rendering: View button only for ACTIVE campaigns</p>"},{"location":"v2/frontend/pages/admin/campaigns-page/#status-colors","title":"Status Colors","text":"<pre><code>const statusColors: Record<CampaignStatus, string> = {\n DRAFT: 'default', // Gray\n ACTIVE: 'green', // Green\n PAUSED: 'orange', // Orange\n ARCHIVED: 'gray', // Gray\n};\n</code></pre>"},{"location":"v2/frontend/pages/admin/campaigns-page/#government-level-colors","title":"Government Level Colors","text":"<pre><code>const govLevelColors: Record<GovernmentLevel, string> = {\n FEDERAL: 'blue',\n PROVINCIAL: 'purple',\n MUNICIPAL: 'cyan',\n SCHOOL_BOARD: 'magenta',\n};\n</code></pre>"},{"location":"v2/frontend/pages/admin/campaigns-page/#feature-flags-form-section","title":"Feature Flags Form Section","text":"<pre><code><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</code></pre> <p>Pattern: 9 switches in 2-column responsive grid (xs: 1 column, sm+: 2 columns)</p>"},{"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":"<p>None \u2014 Campaigns are fetched from API on each page load. No global state required.</p>"},{"location":"v2/frontend/pages/admin/campaigns-page/#local-state","title":"Local State","text":"<pre><code>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</code></pre> <p>Debounced search pattern:</p> <pre><code>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</code></pre> <p>Why 300ms debounce? Prevents API spam while typing. Only fetches when user pauses.</p>"},{"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 <code>/api/campaigns</code> List campaigns (paginated, filtered) POST <code>/api/campaigns</code> Create campaign PUT <code>/api/campaigns/:id</code> Update campaign DELETE <code>/api/campaigns/:id</code> Delete campaign (cascade emails + responses)"},{"location":"v2/frontend/pages/admin/campaigns-page/#list-campaigns","title":"List Campaigns","text":"<p>Request:</p> <pre><code>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</code></pre> <p>Response:</p> <pre><code>{\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</code></pre> <p>Key fields: - <code>slug</code> \u2014 URL-friendly identifier (auto-generated from title) - <code>targetGovernmentLevels</code> \u2014 Array of government levels (empty array = all) - <code>_count</code> \u2014 Prisma aggregation with email and response counts - Feature flags \u2014 9 boolean fields controlling campaign behavior</p>"},{"location":"v2/frontend/pages/admin/campaigns-page/#create-campaign","title":"Create Campaign","text":"<p>Request:</p> <pre><code>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</code></pre> <p>Response:</p> <pre><code>{\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</code></pre> <p>Slug generation: Backend auto-generates slug from title (lowercase, hyphens replace spaces/punctuation)</p>"},{"location":"v2/frontend/pages/admin/campaigns-page/#update-campaign","title":"Update Campaign","text":"<p>Request:</p> <pre><code>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</code></pre> <p>Response:</p> <pre><code>{\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</code></pre> <p>Partial updates: Only send changed fields, backend merges with existing record.</p>"},{"location":"v2/frontend/pages/admin/campaigns-page/#delete-campaign","title":"Delete Campaign","text":"<p>Request:</p> <pre><code>await api.delete(`/campaigns/${campaignId}`);\n</code></pre> <p>Response: 204 No Content</p> <p>Cascade behavior: Prisma cascade deletes: - All CampaignEmail records (sent emails) - All Response records (public responses) - All PostalCodeCache entries referencing this campaign</p> <p>Warning: Shown in Popconfirm: \"All associated emails and responses will also be deleted.\"</p>"},{"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":"<pre><code>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</code></pre> <p>Benefits: - User sees immediate feedback in input (controlled) - API only called once per 300ms (prevents spam) - Timer cleared on unmount (no memory leaks)</p>"},{"location":"v2/frontend/pages/admin/campaigns-page/#usecallback-optimization","title":"useCallback Optimization","text":"<pre><code>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</code></pre> <p>Why useCallback? Memoizes function, prevents re-creating on every render. Dependencies array ensures function updates when pagination, search, or filter changes.</p>"},{"location":"v2/frontend/pages/admin/campaigns-page/#color-coded-government-level-tags","title":"Color-Coded Government Level Tags","text":"<pre><code>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</code></pre> <p>Pattern: Map each government level to a colored tag, replace underscores with spaces for readability.</p>"},{"location":"v2/frontend/pages/admin/campaigns-page/#reusable-form-fields-component","title":"Reusable Form Fields Component","text":"<pre><code>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</code></pre> <p>Benefits: - DRY principle (Don't Repeat Yourself) - Single source of truth for form structure - Easy to add/modify fields in one place</p>"},{"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":"<p>300ms debounce prevents API spam: - User typing \"climate action\" fires 1 API call (not 14) - Reduces server load, improves responsiveness - Uses <code>clearTimeout</code> to cancel pending calls</p>"},{"location":"v2/frontend/pages/admin/campaigns-page/#responsive-column-hiding","title":"Responsive Column Hiding","text":"<pre><code>{\n title: 'Gov. Levels',\n responsive: ['md'], // Hide on screens < 768px\n}\n</code></pre> <p>Benefits: - Mobile users see only essential columns (Title, Status, Actions) - Desktop users see full details - No horizontal scrolling on mobile</p>"},{"location":"v2/frontend/pages/admin/campaigns-page/#usecallback-memoization","title":"useCallback Memoization","text":"<pre><code>const fetchCampaigns = useCallback(async (params) => {\n // ... fetch logic\n}, [pagination.page, pagination.limit, debouncedSearch, statusFilter]);\n</code></pre> <p>Benefits: - Function reference stable unless dependencies change - Prevents unnecessary re-renders in child components - Avoids infinite re-render loops</p>"},{"location":"v2/frontend/pages/admin/campaigns-page/#pagination","title":"Pagination","text":"<p>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)</p>"},{"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":"<ul> <li>Table: Single column layout</li> <li>Title + star icon (if highlighted)</li> <li>Status tag</li> <li>Actions column (all 5 buttons visible)</li> <li>Gov. Levels, Emails, Responses, Created columns hidden</li> <li>Search bar: Full width</li> <li>Status filter: Full width below search</li> <li>Feature flags: Single column (xs={24})</li> </ul>"},{"location":"v2/frontend/pages/admin/campaigns-page/#tablet-576px-992px","title":"Tablet (576px - 992px)","text":"<ul> <li>Table: Gov. Levels, Emails, Created columns visible</li> <li>Responses column still hidden (lg+)</li> <li>Search bar: Half width (sm={12})</li> <li>Status filter: Quarter width (sm={6})</li> <li>Feature flags: 2 columns (sm={12})</li> </ul>"},{"location":"v2/frontend/pages/admin/campaigns-page/#desktop-992px","title":"Desktop (\u2265 992px)","text":"<ul> <li>Table: All columns visible</li> <li>Filters: Compact layout (search \u2153 width, filter \u2159 width)</li> <li>Feature flags: 2 columns with comfortable spacing</li> </ul>"},{"location":"v2/frontend/pages/admin/campaigns-page/#accessibility","title":"Accessibility","text":"<ul> <li>Keyboard navigation: All buttons, inputs, selects focusable via Tab</li> <li>ARIA labels: Icon buttons have <code>title</code> attribute for tooltips</li> <li>Form validation: Required fields marked with red asterisk, inline error messages</li> <li>Color contrast: Status tags use Ant Design default colors (WCAG AA compliant)</li> <li>Screen reader support: Form.Item labels properly associated with inputs</li> <li>Focus management: Modal auto-focuses first input on open</li> </ul>"},{"location":"v2/frontend/pages/admin/campaigns-page/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/frontend/pages/admin/campaigns-page/#campaign-not-appearing-on-public-page","title":"Campaign Not Appearing on Public Page","text":"<p>Problem: Created campaign, set status to ACTIVE, but <code>/campaigns</code> page doesn't show it.</p> <p>Diagnosis:</p> <p>Check status in campaigns table: <pre><code>campaigns.find((c) => c.slug === 'my-campaign')?.status // Should be \"ACTIVE\"\n</code></pre></p> <p>Common Issues:</p> <ol> <li>Status still DRAFT:</li> <li>Edit campaign</li> <li>Change Status dropdown from DRAFT to ACTIVE</li> <li> <p>Click Save</p> </li> <li> <p>Browser cache:</p> </li> <li>Hard refresh public page (Ctrl+Shift+R)</li> <li> <p>Or clear browser cache</p> </li> <li> <p>Campaign created but not saved:</p> </li> <li>Check for error message after clicking Create</li> <li>Verify required fields filled (Title, Email Subject, Email Body)</li> </ol> <p>Solution: Always verify status is ACTIVE after creating campaign. Status defaults to DRAFT.</p>"},{"location":"v2/frontend/pages/admin/campaigns-page/#emails-drawer-shows-0-emails","title":"Emails Drawer Shows 0 Emails","text":"<p>Problem: Click Mail icon for ACTIVE campaign, drawer shows 0 emails.</p> <p>Diagnosis:</p> <p>Campaign might be active but no one has sent emails yet: <pre><code>campaign._count.emails === 0 // No emails sent via this campaign\n</code></pre></p> <p>Common Issues:</p> <ol> <li>Campaign just published:</li> <li>No users have accessed public page yet</li> <li> <p>Share campaign link to supporters</p> </li> <li> <p>SMTP not configured:</p> </li> <li>Check Settings \u2192 Email tab</li> <li>Verify Production SMTP credentials</li> <li> <p>Test connection</p> </li> <li> <p>BullMQ queue not running:</p> </li> <li>Check docker-compose logs: <code>docker compose logs email-worker</code></li> <li>Verify redis container running</li> </ol> <p>Solution: Emails drawer shows historical data. If campaign is new, wait for users to send emails via public page.</p>"},{"location":"v2/frontend/pages/admin/campaigns-page/#copy-link-button-not-working","title":"Copy Link Button Not Working","text":"<p>Problem: Click Link icon, no success message, clipboard empty.</p> <p>Diagnosis:</p> <p>Check browser console for errors: <pre><code>DOMException: Document is not focused\n</code></pre></p> <p>Common Issue:</p> <p>Browser security blocks clipboard access if page not focused.</p> <p>Solution:</p> <ol> <li>Click anywhere on page to focus</li> <li>Retry Copy Link button</li> <li>Or manually copy slug from table: <code>/campaign/{slug}</code></li> </ol>"},{"location":"v2/frontend/pages/admin/campaigns-page/#duplicate-campaign-titles","title":"Duplicate Campaign Titles","text":"<p>Problem: Create campaign with same title as existing, backend allows it.</p> <p>Diagnosis:</p> <p>Backend auto-generates unique slug by appending numbers: <pre><code>\"Climate Action\" \u2192 \"climate-action\"\n\"Climate Action\" (duplicate) \u2192 \"climate-action-1\"\n\"Climate Action\" (duplicate 2) \u2192 \"climate-action-2\"\n</code></pre></p> <p>Not an error: Duplicate titles allowed, slugs remain unique.</p> <p>Best Practice: Use unique, descriptive titles to avoid confusion: - \u274c \"Climate Action\" (generic) - \u2705 \"Climate Action: Support Bill C-12\" (specific)</p>"},{"location":"v2/frontend/pages/admin/campaigns-page/#delete-confirmation-not-showing","title":"Delete Confirmation Not Showing","text":"<p>Problem: Click Delete icon, campaign deletes immediately without confirmation.</p> <p>Diagnosis:</p> <p>Check Popconfirm placement in table Actions column: <pre><code><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</code></pre></p> <p>Solution: Popconfirm wraps the Button. If Popconfirm missing, delete happens immediately. Always use Popconfirm for destructive actions.</p>"},{"location":"v2/frontend/pages/admin/campaigns-page/#related-documentation","title":"Related Documentation","text":"<ul> <li>Campaigns Module (Backend) \u2014 API implementation, schemas, service functions</li> <li>Campaign Emails Module \u2014 Email tracking, stats, sent emails</li> <li>Responses Module \u2014 Response wall, moderation, upvoting</li> <li>CampaignEmailsDrawer Component \u2014 Email statistics drawer</li> <li>Public Campaign Page \u2014 Public-facing campaign detail page</li> <li>Campaigns API Reference \u2014 Complete endpoint documentation</li> <li>Influence Feature Guide \u2014 End-to-end campaign workflow</li> <li>User Guide: Campaign Management \u2014 Step-by-step campaign creation guide</li> </ul>"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/","title":"CanvassDashboardPage","text":""},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#overview","title":"Overview","text":"<p>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.</p> <p>Route: <code>/app/canvass/dashboard</code> Component: <code>admin/src/pages/CanvassDashboardPage.tsx</code> (316 lines) Auth Required: Yes (SUPER_ADMIN or MAP_ADMIN role recommended) Layout: AppLayout Backend Module: <code>api/src/modules/map/canvass/</code></p>"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#screenshot","title":"Screenshot","text":"<p>[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.]</p>"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#features","title":"Features","text":"<ul> <li>Real-time statistics \u2014 Total visits, active volunteers, active sessions, average visits per session</li> <li>Auto-refresh \u2014 Updates every 30 seconds automatically (configurable interval)</li> <li>Activity feed \u2014 Chronological list of recent visits with outcome, address, timestamp</li> <li>Cut progress tracking \u2014 Progress bars showing visit completion percentage per cut</li> <li>Volunteer leaderboard \u2014 Top 10 volunteers ranked by total visit count</li> <li>Live volunteer map \u2014 Interactive Leaflet map showing active volunteers' GPS positions</li> <li>Manual refresh \u2014 Refresh button to update data immediately</li> <li>Responsive design \u2014 Two-column layout on desktop, stacked on mobile</li> <li>Color-coded outcomes \u2014 Visit outcomes highlighted with semantic colors (green, red, orange, blue)</li> <li>Relative timestamps \u2014 Human-readable time since visit (e.g., \"2 mins ago\", \"1 hour ago\")</li> <li>Empty states \u2014 Friendly messages when no data available</li> <li>Cut filtering \u2014 Click cut name in progress list to view cut details</li> </ul>"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#user-workflow","title":"User Workflow","text":""},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#monitoring-active-canvassing","title":"Monitoring Active Canvassing","text":"<ol> <li>Navigate to <code>/app/canvass/dashboard</code></li> <li>Page loads with initial data fetch</li> <li>View statistics cards (top row):</li> <li>Total Visits: All-time visit count across all cuts</li> <li>Active Volunteers: Currently signed in with active sessions</li> <li>Active Sessions: Currently ACTIVE sessions (not COMPLETED or ABANDONED)</li> <li>Avg Visits per Session: Total visits / total sessions</li> <li>Observe auto-refresh indicator:</li> <li>Page refreshes every 30 seconds</li> <li>No loading spinner (silent refresh)</li> <li>Data updates smoothly without UI flicker</li> <li>Monitor activity feed (left column):</li> <li>See most recent 20 visits</li> <li>Each entry shows: volunteer name, outcome, address, relative time</li> <li>Color-coded by outcome (Answered=green, Not Home=red, etc.)</li> <li>Auto-scrolls to top when new visits appear</li> <li>Track cut progress (right column, top):</li> <li>See all cuts with visit counts</li> <li>Progress bars show completion percentage</li> <li>Percentage calculated as (visits / locations) \u00d7 100%</li> <li>View volunteer leaderboard (right column, bottom):</li> <li>Top 10 volunteers by visit count</li> <li>Shows total visits per volunteer</li> <li>Ranked 1<sup>st</sup> to 10<sup>th</sup> place</li> <li>Use live map (bottom):</li> <li>See active volunteers as blue circle markers</li> <li>View cut polygons as colored overlays</li> <li>Zoom and pan to explore territory</li> <li>Hover over markers for volunteer name and current location</li> </ol>"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#responding-to-activity","title":"Responding to Activity","text":"<ol> <li>Notice new visit in activity feed (e.g., \"Jane Doe - ANSWERED - 456 Oak Ave - Just now\")</li> <li>Click cut name in progress section to view cut details:</li> <li>Navigates to <code>/app/map/cuts?id={cutId}</code></li> <li>Opens CutsPage filtered to that cut</li> <li>Can view all locations, edit cut, or export data</li> <li>Click volunteer name in leaderboard to view volunteer details:</li> <li>Navigates to <code>/app/users?id={userId}</code></li> <li>Opens UsersPage filtered to that user</li> <li>Can edit user info, assign roles, or view all visits</li> <li>Click refresh button (top-right, next to title) to force immediate data update:</li> <li>Fetches latest data from API</li> <li>Updates all sections simultaneously</li> <li>Useful when expecting urgent update (e.g., shift just ended)</li> </ol>"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#identifying-issues","title":"Identifying Issues","text":"<ol> <li>No Active Volunteers:</li> <li>Statistics show \"Active Volunteers: 0\"</li> <li>Activity feed is empty or stale</li> <li> <p>Action: Check if any shifts are scheduled, contact volunteers to start shifts</p> </li> <li> <p>High \"Not Home\" Rate:</p> </li> <li>Activity feed shows many red \"NOT_HOME\" entries</li> <li> <p>Action: Consider rescheduling shifts to evening hours when residents more likely home</p> </li> <li> <p>Stalled Sessions:</p> </li> <li>Active Sessions count doesn't decrease over time</li> <li> <p>Action: Check for abandoned sessions (volunteers forgot to end session), manually close via backend</p> </li> <li> <p>Volunteers Off Course:</p> </li> <li>Live map shows volunteer marker far from assigned cut polygon</li> <li> <p>Action: Contact volunteer to redirect back to assigned territory</p> </li> <li> <p>Low Visits per Session:</p> </li> <li>Average visits per session below expected rate (e.g., < 10)</li> <li>Action: Investigate if locations are too far apart, provide walking route optimization</li> </ol>"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#using-live-map","title":"Using Live Map","text":"<ol> <li>Scroll to \"Live Volunteer Map\" card at bottom</li> <li>Map loads with:</li> <li>Cut polygons as colored overlays (semi-transparent fill)</li> <li>Active volunteer markers as blue circles</li> <li>Legend in bottom-right corner</li> <li>Zoom controls:</li> <li>Plus (+) button: Zoom in</li> <li>Minus (\u2212) button: Zoom out</li> <li>Scroll wheel: Zoom in/out</li> <li>Pan:</li> <li>Click and drag map to move view</li> <li>Double-click to zoom in on point</li> <li>Marker interaction:</li> <li>Hover over blue marker: Tooltip shows volunteer name</li> <li>Click marker: Opens popup with volunteer details (name, current session, visit count)</li> <li>Polygon interaction:</li> <li>Hover over cut polygon: Tooltip shows cut name and visit count</li> <li>Click polygon: Navigates to cut details page</li> </ol>"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#component-breakdown","title":"Component Breakdown","text":""},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#ant-design-components-used","title":"Ant Design Components Used","text":"<ul> <li>Typography.Title \u2014 Page heading (\"Canvass Dashboard\")</li> <li>Typography.Text \u2014 Labels, descriptions, empty state text</li> <li>Row / Col \u2014 Grid layout for statistics cards and two-column layout</li> <li>Card \u2014 Container for all sections (stats, activity, progress, leaderboard, map)</li> <li>Statistic \u2014 Formatted numeric statistics display</li> <li>Button \u2014 Refresh button (top-right)</li> <li>List \u2014 Activity feed list</li> <li>List.Item \u2014 Individual activity entries</li> <li>Progress \u2014 Cut progress bars</li> <li>Empty \u2014 Empty state when no data available</li> <li>Tooltip \u2014 Hover tooltips on map markers</li> <li>Spin \u2014 Loading spinner during initial data fetch</li> </ul>"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#custom-components","title":"Custom Components","text":"<ul> <li>AdminMapView \u2014 Leaflet map wrapper with volunteer markers and cut overlays</li> <li>Renders active volunteer positions as blue circle markers</li> <li>Renders cut polygons as colored overlays</li> <li>Provides zoom/pan controls</li> <li>Auto-centers on volunteer cluster</li> </ul>"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#statistics-cards","title":"Statistics Cards","text":"<pre><code><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</code></pre> <p>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)</p> <p>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</p>"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#activity-feed","title":"Activity Feed","text":"<pre><code><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</code></pre> <p>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)</p> <p>Outcome Color Mapping:</p> <pre><code>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</code></pre> <p>Relative Time Formatting:</p> <pre><code>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</code></pre>"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#cut-progress-section","title":"Cut Progress Section","text":"<pre><code><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</code></pre> <p>Progress Calculation:</p> <pre><code>const percentage = Math.round((cut.visitCount / cut.locationCount) * 100);\n</code></pre> <p>Progress Bar States: - Active (< 100%): Blue bar, animated stripes - Success (100%): Green bar, checkmark icon - Empty (0%): Gray bar, no progress</p>"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#volunteer-leaderboard","title":"Volunteer Leaderboard","text":"<pre><code><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</code></pre> <p>Ranking Display: - #1-3: Bold, larger font (emphasize top performers) - #4-10: Standard font - Visit count: Blue badge on right side</p>"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#live-volunteer-map","title":"Live Volunteer Map","text":"<pre><code><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</code></pre> <p>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</p>"},{"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":"<pre><code>// 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</code></pre> <p>No Global State:</p> <p>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</p>"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#auto-refresh-with-useeffect","title":"Auto-Refresh with useEffect","text":"<pre><code>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</code></pre> <p>Auto-Refresh Strategy:</p> <ul> <li>Initial load: Immediate fetch on mount</li> <li>Interval: 30 seconds (30,000 milliseconds)</li> <li>Parallel fetching: 6 API calls executed simultaneously (Promise.all)</li> <li>Silent refresh: No loading spinner on auto-refresh (only on initial load)</li> <li>Cleanup: Clear interval on unmount to prevent memory leak</li> </ul> <p>Why 30 Seconds?</p> <ul> <li>Balance: Frequent enough to feel real-time, infrequent enough to avoid API overload</li> <li>Network efficiency: 6 API calls every 30 seconds = 12 requests/minute (manageable)</li> <li>Battery-friendly: 30-second interval doesn't drain mobile devices excessively</li> <li>Configurable: Can be adjusted via environment variable if needed</li> </ul>"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#usecallback-optimization","title":"useCallback Optimization","text":"<pre><code>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</code></pre> <p>Why useCallback?</p> <ul> <li>Prevents infinite re-renders: Without useCallback, useEffect would create new function reference on every render, triggering effect again</li> <li>Stable interval: Ensures setInterval always references same function instance</li> <li>No dependencies: Empty dependency array means function never re-created</li> </ul>"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#api-integration","title":"API Integration","text":""},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#endpoints-used","title":"Endpoints Used","text":"Method Endpoint Purpose Auth GET <code>/api/canvass/admin/stats</code> Overall statistics Required (ADMIN) GET <code>/api/canvass/admin/recent-activity</code> Recent 20 visits Required (ADMIN) GET <code>/api/canvass/admin/cut-progress</code> Cut-by-cut progress Required (ADMIN) GET <code>/api/canvass/admin/top-volunteers</code> Volunteer leaderboard Required (ADMIN) GET <code>/api/canvass/admin/active-volunteers</code> Live volunteer positions Required (ADMIN) GET <code>/api/cuts</code> All cuts for map Required"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#load-overall-statistics","title":"Load Overall Statistics","text":"<p>Request:</p> <pre><code>const { data } = await api.get<CanvassStats>('/canvass/admin/stats');\n</code></pre> <p>Response (200 OK):</p> <pre><code>{\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</code></pre> <p>Response Fields: - <code>totalVisits</code> (number): All-time visit count across all sessions - <code>activeVolunteers</code> (number): Currently signed in with ACTIVE sessions - <code>activeSessions</code> (number): Sessions with status = ACTIVE (not COMPLETED or ABANDONED) - <code>avgVisitsPerSession</code> (number): Total visits / total sessions (decimal) - <code>breakdown</code> (object): Visit count by outcome type</p> <p>Backend Calculation:</p> <pre><code>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</code></pre>"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#load-recent-activity","title":"Load Recent Activity","text":"<p>Request:</p> <pre><code>const { data } = await api.get<CanvassVisit[]>('/canvass/admin/recent-activity', {\n params: { limit: 20 },\n});\n</code></pre> <p>Query Parameters: - <code>limit</code> (number, optional): Maximum number of visits to return (default: 20)</p> <p>Response (200 OK):</p> <pre><code>[\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</code></pre> <p>Response Fields: - <code>id</code> (string): Unique visit identifier - <code>outcome</code> (string): Visit outcome (ANSWERED, NOT_HOME, MOVED, REFUSED, INACCESSIBLE, OTHER) - <code>address</code> (string): Location address - <code>timestamp</code> (ISO 8601): Visit timestamp - <code>volunteerName</code> (string): Name of volunteer who recorded visit - <code>locationId</code> (string): Associated location ID - <code>sessionId</code> (string): Associated canvass session ID</p> <p>Sorting: - Ordered by <code>timestamp DESC</code> (most recent first)</p>"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#load-cut-progress","title":"Load Cut Progress","text":"<p>Request:</p> <pre><code>const { data } = await api.get<CutProgress[]>('/canvass/admin/cut-progress');\n</code></pre> <p>Response (200 OK):</p> <pre><code>[\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</code></pre> <p>Response Fields: - <code>id</code> (string): Cut identifier - <code>name</code> (string): Cut name - <code>locationCount</code> (number): Total locations in cut - <code>visitCount</code> (number): Number of locations with at least one visit - <code>percentage</code> (number): Completion percentage (rounded to integer)</p> <p>Percentage Calculation:</p> <pre><code>// Backend calculation\nconst percentage = Math.round((visitCount / locationCount) * 100);\n</code></pre> <p>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.</p>"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#load-top-volunteers","title":"Load Top Volunteers","text":"<p>Request:</p> <pre><code>const { data } = await api.get<VolunteerStats[]>('/canvass/admin/top-volunteers', {\n params: { limit: 10 },\n});\n</code></pre> <p>Query Parameters: - <code>limit</code> (number, optional): Maximum number of volunteers to return (default: 10)</p> <p>Response (200 OK):</p> <pre><code>[\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</code></pre> <p>Response Fields: - <code>id</code> (string): User identifier - <code>name</code> (string): Volunteer full name - <code>email</code> (string): Volunteer email - <code>visitCount</code> (number): Total visits recorded by volunteer (all-time) - <code>sessionCount</code> (number): Total sessions completed by volunteer - <code>avgVisitsPerSession</code> (number): Visits / sessions (decimal)</p> <p>Sorting: - Ordered by <code>visitCount DESC</code> (highest visit count first) - Limited to top N volunteers (default 10)</p>"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#load-active-volunteers","title":"Load Active Volunteers","text":"<p>Request:</p> <pre><code>const { data } = await api.get<ActiveVolunteer[]>('/canvass/admin/active-volunteers');\n</code></pre> <p>Response (200 OK):</p> <pre><code>[\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</code></pre> <p>Response Fields: - <code>id</code> (string): User identifier - <code>name</code> (string): Volunteer full name - <code>sessionId</code> (string): Active session identifier - <code>cutId</code> (string): Assigned cut identifier - <code>cutName</code> (string): Assigned cut name - <code>latitude</code> (number): Current GPS latitude - <code>longitude</code> (number): Current GPS longitude - <code>lastUpdate</code> (ISO 8601): Last GPS position update timestamp - <code>visitCount</code> (number): Visits recorded in current session</p> <p>Filtering: - Only includes volunteers with status = ACTIVE sessions - GPS position from most recent TrackPoint record - Excludes volunteers with null GPS coordinates</p> <p>Backend Query:</p> <pre><code>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</code></pre>"},{"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":"<pre><code>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</code></pre> <p>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)</p>"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#auto-refresh-setup","title":"Auto-Refresh Setup","text":"<pre><code>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</code></pre> <p>Cleanup Importance:</p> <p>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</p> <p>Testing Auto-Refresh:</p> <pre><code>// 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</code></pre>"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#manual-refresh-handler","title":"Manual Refresh Handler","text":"<pre><code>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</code></pre> <p>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</p>"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#relative-time-formatting","title":"Relative Time Formatting","text":"<pre><code>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</code></pre> <p>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)</p>"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#outcome-color-mapping","title":"Outcome Color Mapping","text":"<pre><code>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</code></pre> <p>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</p>"},{"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":"<p>Dashboard loads 6 API endpoints simultaneously:</p> <pre><code>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</code></pre> <p>Performance Comparison:</p> <p>Sequential Fetching (bad): <pre><code>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</code></pre></p> <p>Parallel Fetching (good): <pre><code>const allResults = await Promise.all([...]); // max(200ms) = 200ms\n// Total: 200ms (6\u00d7 faster)\n</code></pre></p>"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#silent-auto-refresh","title":"Silent Auto-Refresh","text":"<p>Auto-refresh doesn't show loading spinner:</p> <pre><code>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</code></pre> <p>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</p> <p>Trade-off:</p> <p>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</p>"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#limited-data-sets","title":"Limited Data Sets","text":"<p>API endpoints return limited data:</p> <ul> <li>Recent Activity: 20 most recent visits (not all 1,247 visits)</li> <li>Top Volunteers: 10 highest-ranked volunteers (not all 50 volunteers)</li> <li>Active Volunteers: Only currently active (not all users)</li> <li>Cut Progress: All cuts (typically < 50)</li> </ul> <p>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</p>"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#usecallback-memoization","title":"useCallback Memoization","text":"<p>Fetch function is memoized to prevent re-creation:</p> <pre><code>const loadData = useCallback(async () => {\n // ... fetch logic\n}, []); // Empty dependency array = function never re-created\n</code></pre> <p>Without useCallback: <pre><code>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</code></pre></p> <p>With useCallback: <pre><code>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</code></pre></p>"},{"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":"<p>Dashboard adapts to mobile viewports:</p> <p>Statistics Cards: <pre><code><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</code></pre></p> <p>Two-Column Layout: <pre><code><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</code></pre></p> <p>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)</p>"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#map-height","title":"Map Height","text":"<p>Map adapts to viewport height:</p> <pre><code><Card title=\"Live Volunteer Map\">\n <div style={{ height: 500, minHeight: 300 }}>\n <AdminMapView {...props} />\n </div>\n</Card>\n</code></pre> <p>Responsive Heights: - Desktop: 500px fixed height - Tablet: 400px (less vertical space) - Mobile: 300px minimum height (prevent squishing)</p>"},{"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":"<p>All interactive elements are keyboard-accessible:</p> <p>Refresh Button: - Tab: Focus on refresh button - Enter/Space: Trigger refresh</p> <p>Activity Feed: - Tab: Focus on list items - Enter: Activate clickable items (volunteer name, location) - Arrow Keys: Scroll list (native browser behavior)</p> <p>Map: - Tab: Focus on map container - Arrow Keys: Pan map - +/\u2212: Zoom in/out - Enter: Activate focused marker</p>"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#screen-reader-support","title":"Screen Reader Support","text":"<p>All elements have proper ARIA labels:</p> <p>Statistics Cards: <pre><code><Statistic\n title=\"Total Visits\"\n value={stats.totalVisits}\n aria-label={`Total visits: ${stats.totalVisits}`}\n/>\n</code></pre></p> <p>Activity Feed: <pre><code><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</code></pre></p> <p>Progress Bars: <pre><code><Progress\n percent={cut.percentage}\n aria-label={`${cut.name} progress: ${cut.percentage}% complete`}\n/>\n</code></pre></p>"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#color-contrast","title":"Color Contrast","text":"<p>All color-coded elements meet WCAG AA standards:</p> <p>Outcome Tags: - Green (ANSWERED): <code>#52c41a</code> on white = 3.0:1 contrast (AA for large text) - Red (NOT_HOME): <code>#f5222d</code> on white = 4.5:1 contrast (AA) - Orange (MOVED): <code>#fa8c16</code> on white = 3.3:1 contrast (AA for large text)</p> <p>Statistics Values: - Green: <code>#3f8600</code> on white = 4.8:1 contrast (AA) - Blue: <code>#1890ff</code> on white = 4.5:1 contrast (AA) - Orange: <code>#faad14</code> on white = 3.2:1 contrast (AA for large text)</p>"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#dashboard-not-auto-refreshing","title":"Dashboard Not Auto-Refreshing","text":"<p>Problem: Dashboard loads initially, but data doesn't update after 30 seconds.</p> <p>Diagnosis:</p> <p>Check if interval is set up correctly:</p> <pre><code>useEffect(() => {\n loadData();\n const interval = setInterval(loadData, 30000);\n return () => clearInterval(interval);\n}, [loadData]);\n</code></pre> <p>Open browser console and check for errors:</p> <pre><code>// Expected: No errors every 30 seconds\n// If errors appear every 30 seconds, auto-refresh is running but failing\n</code></pre> <p>Possible Causes:</p> <ol> <li>Interval not set up:</li> <li>Missing <code>setInterval</code> call</li> <li> <p>Interval not returned from useEffect</p> </li> <li> <p>Interval cleared prematurely:</p> </li> <li>Component unmounted and remounted (React Strict Mode in development)</li> <li> <p>Cleanup function called too early</p> </li> <li> <p>API errors silently failing:</p> </li> <li>Backend API down, but error not shown to user</li> <li>JWT token expired, 401 errors swallowed by try/catch</li> </ol> <p>Solution:</p> <ol> <li>Verify interval exists:</li> <li>Add console.log in loadData: <code>console.log('Dashboard refresh:', new Date())</code></li> <li> <p>Check console every 30 seconds for log message</p> </li> <li> <p>Handle React Strict Mode:</p> </li> <li>Accept that development mode unmounts/remounts components</li> <li> <p>Ensure production build works correctly (no double mounting)</p> </li> <li> <p>Show API errors:</p> </li> <li>Remove generic try/catch error handling</li> <li>Let errors bubble up to user as message.error()</li> <li>Add retry logic for transient failures</li> </ol>"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#active-volunteers-0-despite-active-sessions","title":"\"Active Volunteers: 0\" Despite Active Sessions","text":"<p>Problem: Statistics show \"Active Volunteers: 0\" but shifts are scheduled and volunteers are canvassing.</p> <p>Diagnosis:</p> <p>Check active sessions in database:</p> <pre><code>SELECT COUNT(*) FROM \"CanvassSession\" WHERE status = 'ACTIVE';\n-- Expected: > 0 if volunteers are active\n-- Actual: 0 (sessions not marked as ACTIVE)\n</code></pre> <p>Possible Causes:</p> <ol> <li>Sessions not started:</li> <li>Volunteers signed up for shifts but didn't start canvassing</li> <li> <p>No sessions with status = ACTIVE</p> </li> <li> <p>Sessions abandoned:</p> </li> <li>Volunteers forgot to end sessions, sessions auto-closed by backend</li> <li> <p>Sessions marked as ABANDONED instead of ACTIVE</p> </li> <li> <p>Sessions completed:</p> </li> <li>Volunteers ended sessions, now showing as COMPLETED</li> <li>Active count only includes ACTIVE status</li> </ol> <p>Solution:</p> <ol> <li>Contact volunteers:</li> <li>Ask them to start canvassing session from volunteer portal</li> <li> <p>Navigate to <code>/volunteer/assignments</code>, click \"Start Canvassing\"</p> </li> <li> <p>Check abandoned sessions:</p> </li> <li>Navigate to Canvass Dashboard</li> <li>Look for sessions with \"ABANDONED\" status</li> <li> <p>Manually reopen if volunteer is still active</p> </li> <li> <p>Adjust status query:</p> </li> <li>If volunteers frequently forget to end sessions, consider showing ACTIVE + recently updated sessions (< 1 hour ago)</li> </ol>"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#map-not-showing-volunteer-markers","title":"Map Not Showing Volunteer Markers","text":"<p>Problem: Live Volunteer Map loads but shows no blue markers, even though \"Active Volunteers: 8\".</p> <p>Diagnosis:</p> <p>Check active volunteers API response:</p> <pre><code>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</code></pre> <p>Possible Causes:</p> <ol> <li>No GPS tracking enabled:</li> <li>Volunteers have active sessions but GPS tracking not enabled</li> <li> <p>No TrackPoint records exist for sessions</p> </li> <li> <p>Null GPS coordinates:</p> </li> <li>TrackPoint records exist but latitude/longitude are null</li> <li> <p>Backend filters out volunteers without valid coordinates</p> </li> <li> <p>Map zoom level:</p> </li> <li>Volunteers outside current map viewport</li> <li>Auto-center not working correctly</li> </ol> <p>Solution:</p> <ol> <li>Enable GPS tracking:</li> <li>Ensure volunteers grant location permissions in browser</li> <li>Check volunteer portal GPS tracker is running</li> <li> <p>Navigate to <code>/volunteer/canvass/:cutId</code>, verify \"GPS Active\" indicator</p> </li> <li> <p>Check GPS permissions:</p> </li> <li>Ask volunteers to enable location services in browser settings</li> <li>Chrome: Settings \u2192 Privacy \u2192 Site Settings \u2192 Location \u2192 Allow</li> <li> <p>Safari: Preferences \u2192 Websites \u2192 Location \u2192 Allow</p> </li> <li> <p>Zoom out on map:</p> </li> <li>Click zoom out (\u2212) button several times</li> <li>See if markers appear outside initial viewport</li> <li>If yes, auto-center logic is broken (should zoom to fit all markers)</li> </ol>"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#progress-percentages-over-100","title":"Progress Percentages Over 100%","text":"<p>Problem: Cut Progress section shows \"Downtown Core: 157 / 150 locations (105%)\".</p> <p>Diagnosis:</p> <p>Check location count vs. visit count:</p> <pre><code>-- 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</code></pre> <p>Possible Causes:</p> <ol> <li>Locations moved out of cut:</li> <li>Locations visited while in cut, then unassigned from cut</li> <li> <p>Visit records still reference old cutId, inflating count</p> </li> <li> <p>Duplicate visits counted:</p> </li> <li>Multiple visits to same location counted separately</li> <li> <p>Should count unique locations, not total visits</p> </li> <li> <p>Backend calculation bug:</p> </li> <li>Visit count not filtered by current cut membership</li> <li>Includes visits to locations now in different cuts</li> </ol> <p>Solution:</p> <ol> <li>Fix backend query:</li> <li> <p>Only count visits to locations currently in cut: <pre><code>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</code></pre></p> </li> <li> <p>Cap percentage at 100%:</p> </li> <li> <p>Frontend safety check: <pre><code>const percentage = Math.min(100, Math.round((visitCount / locationCount) * 100));\n</code></pre></p> </li> <li> <p>Investigate data integrity:</p> </li> <li>Find orphaned visits: <pre><code>SELECT * FROM \"CanvassVisit\"\nWHERE \"locationId\" NOT IN (SELECT id FROM \"Location\");\n</code></pre></li> <li>Delete orphaned visits or reassociate with correct locations</li> </ol>"},{"location":"v2/frontend/pages/admin/canvass-dashboard-page/#related-documentation","title":"Related Documentation","text":"<ul> <li>Canvassing Overview \u2014 Volunteer canvassing feature set</li> <li>Canvass Backend Module \u2014 Backend canvass service and API</li> <li>Canvass API Reference \u2014 Admin canvass endpoints</li> <li>Volunteer Map Page \u2014 Volunteer canvassing interface</li> <li>Volunteer Activity Page \u2014 Volunteer visit history</li> <li>CutsPage \u2014 Cut management</li> <li>ShiftsPage \u2014 Shift scheduling</li> <li>LocationsPage \u2014 Location management</li> <li>AdminMapView Component \u2014 Map component with overlays</li> <li>User Guide: Map Organizer \u2014 Canvass management workflow</li> <li>Troubleshooting: GPS Issues \u2014 GPS tracking troubleshooting</li> </ul>"},{"location":"v2/frontend/pages/admin/code-editor-page/","title":"CodeEditorPage","text":""},{"location":"v2/frontend/pages/admin/code-editor-page/#overview","title":"Overview","text":"<p>File: <code>admin/src/pages/CodeEditorPage.tsx</code></p> <p>Route: <code>/app/services/code-editor</code></p> <p>Role Requirements: Any authenticated user (uses <code>authenticate</code> middleware)</p> <p>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.</p> <p>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</p> <p>Layout: AppLayout with fullbleed (no content padding)</p> <p>Dependencies: - Ant Design v5 (Button, Space, Badge, Spin, Grid, Result) - react-router-dom (useOutletContext)</p>"},{"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":"<p>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</p>"},{"location":"v2/frontend/pages/admin/code-editor-page/#2-mobile-device-detection","title":"2. Mobile Device Detection","text":"<p>Mobile Warning Screen: - Detects mobile devices using <code>Grid.useBreakpoint()</code> - Shows warning Result component on mobile - Recommends using desktop for code editing - Icon: CodeOutlined (48px)</p> <p>Breakpoint: <code>!screens.md</code> (screen width < 768px = mobile)</p>"},{"location":"v2/frontend/pages/admin/code-editor-page/#3-code-server-url-construction","title":"3. Code Server URL Construction","text":"<p>URL Building: - Fetches docs config from API (<code>/api/docs/config</code>) - Builds URL using <code>codeServerPort</code> configuration - Uses hostname + port pattern - Example: <code>http://localhost:8888</code> or <code>http://code.cmlite.org</code></p>"},{"location":"v2/frontend/pages/admin/code-editor-page/#4-iframe-embedding","title":"4. Iframe Embedding","text":"<p>Fullbleed Layout: - No padding around iframe - Height: <code>calc(100vh - 64px)</code> (full viewport height minus header) - Width: 100% - No border for seamless VS Code integration</p>"},{"location":"v2/frontend/pages/admin/code-editor-page/#user-workflow","title":"User Workflow","text":""},{"location":"v2/frontend/pages/admin/code-editor-page/#accessing-code-server","title":"Accessing Code Server","text":"<ol> <li>Navigate to Code Editor:</li> <li>Click \"Services\" \u2192 \"Code Editor\" in sidebar</li> <li> <p>Page loads with status check</p> </li> <li> <p>Check Service Status:</p> </li> <li> <p>Status badge appears in page header:</p> <ul> <li>\u2705 \"Online\" (green) - Service available</li> <li>\u274c \"Offline\" (red) - Service unavailable</li> <li>\ud83d\udd35 \"Checking...\" (blue) - Status check in progress</li> </ul> </li> <li> <p>View on Desktop:</p> </li> <li> <p>If on desktop (screen width \u2265 768px):</p> <ul> <li>Iframe loads automatically</li> <li>Full VS Code interface embedded</li> <li>Can edit files, run terminal commands, use extensions</li> </ul> </li> <li> <p>View on Mobile:</p> </li> <li> <p>If on mobile (screen width < 768px):</p> <ul> <li>Warning message appears</li> <li>Message: \"The code editor requires a desktop browser\"</li> <li>\"Open in New Tab\" button provided</li> </ul> </li> <li> <p>Using Code Server:</p> </li> <li>File Explorer: Browse project files in sidebar</li> <li>Editor: Edit code with syntax highlighting, IntelliSense</li> <li>Terminal: Run bash commands (<code>npm</code>, <code>git</code>, <code>docker</code>)</li> <li>Extensions: Install VS Code extensions</li> <li>Search: Global file search (Ctrl+P)</li> <li> <p>Git: Source control integration</p> </li> <li> <p>Common Tasks:</p> </li> <li>Edit API routes: <code>/api/src/modules/</code></li> <li>Edit admin pages: <code>/admin/src/pages/</code></li> <li>Run migrations: Terminal \u2192 <code>cd api && npx prisma migrate dev</code></li> <li>Start dev servers: Terminal \u2192 <code>npm run dev</code></li> <li>View logs: Terminal \u2192 <code>docker compose logs -f api</code></li> </ol>"},{"location":"v2/frontend/pages/admin/code-editor-page/#component-breakdown","title":"Component Breakdown","text":""},{"location":"v2/frontend/pages/admin/code-editor-page/#main-component-structure","title":"Main Component Structure","text":"<pre><code>export default function CodeEditorPage() {\n const { setPageHeader } = useOutletContext<AppOutletContext>();\n const screens = Grid.useBreakpoint();\n const isMobile = !screens.md;\n\n const [online, setOnline] = useState<boolean | null>(null);\n const [codeServerPort, setCodeServerPort] = useState<number | null>(null);\n const [loading, setLoading] = useState(true);\n\n // Fetch service status and config\n const fetchStatus = useCallback(async () => {\n try {\n const [statusRes, configRes] = await Promise.all([\n api.get<DocsStatus>('/docs/status'),\n api.get<DocsConfig>('/docs/config'),\n ]);\n setOnline(statusRes.data.codeServer.online);\n setCodeServerPort(configRes.data.codeServerPort);\n } catch {\n setOnline(false);\n } finally {\n setLoading(false);\n }\n }, []);\n\n useEffect(() => {\n fetchStatus();\n }, [fetchStatus]);\n\n // Build service URL\n const codeServerUrl = codeServerPort\n ? `//${window.location.hostname}:${codeServerPort}`\n : null;\n\n // Page header with status badge and actions\n const headerActions = useMemo(() => (\n <Space>\n <Badge\n status={online === null ? 'processing' : online ? 'success' : 'error'}\n text={online === null ? 'Checking...' : online ? 'Online' : 'Offline'}\n />\n <Button icon={<ReloadOutlined />} onClick={fetchStatus} size=\"small\">\n Refresh\n </Button>\n {codeServerUrl && (\n <Button icon={<LinkOutlined />} href={codeServerUrl} target=\"_blank\" size=\"small\">\n Open in New Tab\n </Button>\n )}\n </Space>\n ), [online, fetchStatus, codeServerUrl]);\n\n useEffect(() => {\n setPageHeader({ title: 'Code Editor', actions: headerActions, fullBleed: true });\n return () => setPageHeader(null);\n }, [setPageHeader, headerActions]);\n\n // Mobile warning\n if (isMobile) {\n return (\n <Result\n status=\"info\"\n title=\"Desktop Required\"\n subTitle=\"The code editor requires a desktop browser with a larger screen.\"\n icon={<CodeOutlined style={{ fontSize: 48 }} />}\n />\n );\n }\n\n // Loading state\n if (loading) {\n return (\n <div style={{ textAlign: 'center', padding: 80 }}>\n <Spin size=\"large\" />\n </div>\n );\n }\n\n // Offline state\n if (!online || !codeServerUrl) {\n return (\n <Result\n status=\"error\"\n title=\"Code Server Unavailable\"\n subTitle=\"Code Server is not running or could not be reached. Check that the code-server container is started.\"\n extra={\n <Button type=\"primary\" onClick={fetchStatus}>\n Retry\n </Button>\n }\n />\n );\n }\n\n // Iframe embed\n return (\n <iframe\n src={codeServerUrl}\n style={{\n width: '100%',\n height: 'calc(100vh - 64px)',\n border: 'none',\n display: 'block',\n }}\n title=\"Code Server\"\n />\n );\n}\n</code></pre>"},{"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":"<pre><code>// 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</code></pre>"},{"location":"v2/frontend/pages/admin/code-editor-page/#state-flow","title":"State Flow","text":"<ol> <li>Component Mounts:</li> <li><code>fetchStatus()</code> called</li> <li>Parallel API calls:<ul> <li><code>GET /api/docs/status</code> - Check Code Server online status</li> <li><code>GET /api/docs/config</code> - Fetch port configuration</li> </ul> </li> <li>Sets <code>online</code> and <code>codeServerPort</code></li> <li> <p>Constructs URL: <code>//${hostname}:${port}</code></p> </li> <li> <p>URL Construction:</p> </li> <li>Uses current hostname (from <code>window.location.hostname</code>)</li> <li>Appends Code Server port (default: 8888)</li> <li>Example: <code>//localhost:8888</code> or <code>//app.cmlite.org:8888</code></li> </ol>"},{"location":"v2/frontend/pages/admin/code-editor-page/#api-integration","title":"API Integration","text":""},{"location":"v2/frontend/pages/admin/code-editor-page/#endpoints-used","title":"Endpoints Used","text":"<ol> <li>GET /api/docs/status - Check MkDocs and Code Server health</li> <li>GET /api/docs/config - Fetch Code Server port configuration</li> </ol>"},{"location":"v2/frontend/pages/admin/code-editor-page/#example-api-calls","title":"Example API Calls","text":""},{"location":"v2/frontend/pages/admin/code-editor-page/#1-fetch-service-status","title":"1. Fetch Service Status","text":"<pre><code>const statusRes = await api.get<DocsStatus>('/docs/status');\nsetOnline(statusRes.data.codeServer.online);\n</code></pre> <p>Response Format: <pre><code>{\n \"mkdocs\": { \"online\": true },\n \"codeServer\": { \"online\": true }\n}\n</code></pre></p>"},{"location":"v2/frontend/pages/admin/code-editor-page/#2-fetch-config","title":"2. Fetch Config","text":"<pre><code>const configRes = await api.get<DocsConfig>('/docs/config');\nsetCodeServerPort(configRes.data.codeServerPort);\n</code></pre> <p>Response Format: <pre><code>{\n \"mkdocsPort\": 4003,\n \"codeServerPort\": 8888\n}\n</code></pre></p>"},{"location":"v2/frontend/pages/admin/code-editor-page/#3-build-url","title":"3. Build URL","text":"<pre><code>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</code></pre>"},{"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":"<pre><code>const [statusRes, configRes] = await Promise.all([\n api.get<DocsStatus>('/docs/status'),\n api.get<DocsConfig>('/docs/config'),\n]);\n</code></pre> <p>Benefit: Reduces total loading time by ~50%.</p>"},{"location":"v2/frontend/pages/admin/code-editor-page/#2-early-mobile-detection","title":"2. Early Mobile Detection","text":"<pre><code>if (isMobile) {\n return <Result />; // No API calls, no iframe\n}\n</code></pre> <p>Benefit: Saves bandwidth and API requests on mobile devices.</p>"},{"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":"<pre><code>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</code></pre> <p>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</p>"},{"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":"<p>Solutions:</p> <ol> <li> <p>Check Docker container: <pre><code>docker compose ps code-server\n</code></pre></p> </li> <li> <p>Check logs: <pre><code>docker compose logs code-server\n</code></pre></p> </li> <li> <p>Test direct access:</p> </li> <li> <p>Open <code>http://localhost:8888</code> in browser</p> </li> <li> <p>Restart service: <pre><code>docker compose restart code-server\n</code></pre></p> </li> </ol>"},{"location":"v2/frontend/pages/admin/code-editor-page/#problem-iframe-not-loading","title":"Problem: Iframe Not Loading","text":"<p>Solutions:</p> <ol> <li>Check password:</li> <li>Code Server requires password authentication</li> <li> <p>Check <code>CODE_SERVER_PASSWORD</code> env var in <code>.env</code></p> </li> <li> <p>Check CSP headers:</p> </li> <li>Open DevTools Console</li> <li> <p>Look for Content Security Policy errors</p> </li> <li> <p>Try \"Open in New Tab\":</p> </li> <li>Click button to test service directly</li> </ol>"},{"location":"v2/frontend/pages/admin/code-editor-page/#related-documentation","title":"Related Documentation","text":"<ul> <li>Code Server Setup - Docker container configuration</li> <li>Development Workflow - Using Code Server for development</li> <li>Docs API - Status and config endpoints</li> </ul>"},{"location":"v2/frontend/pages/admin/cut-export-page/","title":"CutExportPage","text":""},{"location":"v2/frontend/pages/admin/cut-export-page/#overview","title":"Overview","text":"<p>File: <code>admin/src/pages/CutExportPage.tsx</code></p> <p>Route: <code>/app/map/cuts/:id/export</code></p> <p>Role Requirements: Any authenticated admin user (uses <code>authenticate</code> middleware + admin role check)</p> <p>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.</p> <p>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 <code>@media print</code> rules - Landscape orientation for wide table layout</p> <p>Layout: Full AppLayout with \"Back to Cuts\" and \"Print\" buttons in header</p> <p>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</p>"},{"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":"<p>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</p> <p>Purpose: Provides context for the location report</p>"},{"location":"v2/frontend/pages/admin/cut-export-page/#2-statistics-grid","title":"2. Statistics Grid","text":"<p>9 Statistics Cards:</p> <ol> <li>Total: Total number of addresses in cut</li> <li>Strong: Count of LEVEL_1 support (strong supporters)</li> <li>Likely: Count of LEVEL_2 support (likely supporters)</li> <li>Unsure: Count of LEVEL_3 support (undecided voters)</li> <li>Oppose: Count of LEVEL_4 support (opponents)</li> <li>None: Count of addresses with no support level assigned</li> <li>Signs: Count of addresses requesting lawn signs</li> <li>Email: Count of addresses with email addresses</li> <li>Phone: Count of addresses with phone numbers</li> </ol> <p>Color-Coded Values: - Strong Support: Green (<code>#52c41a</code>) - Likely Support: Cyan (<code>#13c2c2</code>) - Unsure: Orange (<code>#faad14</code>) - Oppose: Red (<code>#ff4d4f</code>) - None/Other: Default gray</p> <p>Layout: Responsive grid (9 cards, 3 per row on desktop, 2 per row on mobile)</p>"},{"location":"v2/frontend/pages/admin/cut-export-page/#3-address-table","title":"3. Address Table","text":"<p>8 Columns:</p> <ol> <li>Name: First name + last name (combined)</li> <li>Address: Building street address + unit number (if multi-unit)</li> <li>Support: Support level tag (Strong/Likely/Unsure/Oppose/None)</li> <li>Phone: Phone number or \"--\"</li> <li>Email: Email address or \"--\"</li> <li>Sign: Sign interest (\"Yes\" with size, or \"No\")</li> <li>Notes: Additional notes (ellipsis if long)</li> </ol> <p>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)</p>"},{"location":"v2/frontend/pages/admin/cut-export-page/#4-footer","title":"4. Footer","text":"<p>Footer Text: \"Generated by Changemaker Lite \u2014 {timestamp}\"</p> <p>Purpose: Attribution and timestamp for report archiving</p>"},{"location":"v2/frontend/pages/admin/cut-export-page/#5-print-optimization","title":"5. Print Optimization","text":"<p>CSS @media print Rules: - Hides everything except <code>.cut-export-print</code> container - Positions report at absolute top-left with fixed position - Uses landscape orientation (<code>@page { size: letter landscape; }</code>) - Reduces font size to 9-10px for compact printing - Optimizes table padding and borders for clarity - Forces exact color printing with <code>print-color-adjust: exact</code></p> <p>Print Trigger: \"Print\" button in page header (calls <code>window.print()</code>)</p>"},{"location":"v2/frontend/pages/admin/cut-export-page/#user-workflow","title":"User Workflow","text":""},{"location":"v2/frontend/pages/admin/cut-export-page/#exporting-a-cut-report","title":"Exporting a Cut Report","text":"<ol> <li>Navigate to Cuts:</li> <li>Click \"Map\" \u2192 \"Cuts\" in sidebar</li> <li> <p>Cuts table loads</p> </li> <li> <p>Select Cut:</p> </li> <li>Find cut to export in table</li> <li>Click \"Export\" action button (or similar)</li> <li> <p>Route navigates to <code>/app/map/cuts/:id/export</code></p> </li> <li> <p>Review Report Preview:</p> </li> <li>Page loads with cut metadata header</li> <li>Statistics cards show support level distribution</li> <li> <p>Address table lists all locations in cut</p> </li> <li> <p>Print Report:</p> </li> <li>Click \"Print\" button in page header</li> <li>OR press <code>Ctrl+P</code> (Windows/Linux) or <code>Cmd+P</code> (Mac)</li> <li> <p>Browser print dialog opens</p> </li> <li> <p>Configure Print Settings:</p> </li> <li>Orientation: Landscape (automatically set by CSS)</li> <li>Paper Size: Letter (8.5\" \u00d7 11\")</li> <li>Margins: Minimal (0.25\")</li> <li> <p>Background graphics: ON (to print color tags and borders)</p> </li> <li> <p>Print or Save PDF:</p> </li> <li>Click \"Print\" to send to printer</li> <li>OR select \"Save as PDF\" to create digital copy</li> <li>Report saved/printed for field use</li> </ol>"},{"location":"v2/frontend/pages/admin/cut-export-page/#analyzing-cut-statistics","title":"Analyzing Cut Statistics","text":"<ol> <li>Review Statistics Cards:</li> <li>Total: Understand cut size (e.g., 150 addresses)</li> <li>Strong + Likely: Identify supporter base (e.g., 80 strong + 30 likely = 110 supporters)</li> <li>Unsure: Target for persuasion (e.g., 40 undecided)</li> <li>Oppose: Avoid during canvassing (e.g., 10 opponents)</li> <li> <p>None: Not yet contacted (e.g., 10 addresses)</p> </li> <li> <p>Calculate Support Percentage:</p> </li> <li>Strong Support % = (Strong / Total) \u00d7 100</li> <li> <p>Example: (80 / 150) \u00d7 100 = 53.3% strong support</p> </li> <li> <p>Assess Contact Coverage:</p> </li> <li>Email: Contact via email campaigns (e.g., 90 emails = 60% coverage)</li> <li>Phone: Contact via phone banking (e.g., 100 phones = 67% coverage)</li> <li> <p>Signs: Distribute lawn signs (e.g., 50 sign requests)</p> </li> <li> <p>Plan Canvassing Strategy:</p> </li> <li>High support areas: Focus on turnout (ensure supporters vote)</li> <li>High unsure areas: Focus on persuasion (door-to-door conversations)</li> <li>High oppose areas: Skip or minimal contact (avoid antagonism)</li> </ol>"},{"location":"v2/frontend/pages/admin/cut-export-page/#using-report-for-canvassing","title":"Using Report for Canvassing","text":"<ol> <li>Print Report Before Canvassing:</li> <li>Export cut report</li> <li>Print landscape orientation</li> <li> <p>Bring printed report to field</p> </li> <li> <p>Review Addresses During Canvass:</p> </li> <li>Check support level before knocking</li> <li>Note contact info (phone/email) for follow-up</li> <li> <p>See sign requests (bring signs to those addresses)</p> </li> <li> <p>Update Notes During Canvass:</p> </li> <li>Handwrite additional notes on printed report (e.g., \"Not home\", \"Call back after 6pm\")</li> <li> <p>Mark addresses as \"Visited\" with checkmarks</p> </li> <li> <p>Data Entry After Canvass:</p> </li> <li>Return to office with updated report</li> <li>Enter new data into LocationsPage or AddressPage</li> <li>Update support levels, contact info, notes</li> </ol>"},{"location":"v2/frontend/pages/admin/cut-export-page/#component-breakdown","title":"Component Breakdown","text":""},{"location":"v2/frontend/pages/admin/cut-export-page/#main-component-structure","title":"Main Component Structure","text":"<pre><code>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</code></pre>"},{"location":"v2/frontend/pages/admin/cut-export-page/#ant-design-components-used","title":"Ant Design Components Used","text":"<ol> <li>Button - \"Back to Cuts\" and \"Print\" buttons</li> <li>Typography.Title - Cut name heading</li> <li>Typography.Text - Labels, timestamps, footer</li> <li>Spin - Loading indicator during data fetch</li> <li>Space - Button grouping, tag grouping</li> <li>Table - Address data grid</li> <li>Tag - Cut category, support levels</li> <li>Row / Col - Statistics grid layout</li> <li>Card - Statistics card containers</li> <li>Statistic - Numerical statistics display</li> <li>message - Toast notifications for errors</li> </ol>"},{"location":"v2/frontend/pages/admin/cut-export-page/#table-column-definition","title":"Table Column Definition","text":"<pre><code>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</code></pre>"},{"location":"v2/frontend/pages/admin/cut-export-page/#print-css-styling","title":"Print CSS Styling","text":"<pre><code><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</code></pre> <p>Key Print Rules: - <code>visibility: hidden !important</code> on all elements except <code>.cut-export-print</code> - Fixed positioning at top-left (0, 0) with 0.4in padding - 9-10px font sizes for compact printing - <code>print-color-adjust: exact</code> forces exact color printing (tags, statistics) - Landscape orientation via <code>@page { size: letter landscape; }</code> - Minimal margins (0.25in) to maximize table width</p>"},{"location":"v2/frontend/pages/admin/cut-export-page/#state-management","title":"State Management","text":""},{"location":"v2/frontend/pages/admin/cut-export-page/#local-component-state-usestate","title":"Local Component State (useState)","text":"<p>No Zustand stores used - All state managed locally with React hooks.</p> <pre><code>// 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</code></pre>"},{"location":"v2/frontend/pages/admin/cut-export-page/#state-flow","title":"State Flow","text":"<ol> <li>Component Mounts:</li> <li>Extracts <code>id</code> from URL params (<code>:id</code> in <code>/app/map/cuts/:id/export</code>)</li> <li>Calls 3 parallel API requests:<ul> <li><code>GET /api/map/cuts/:id</code> (cut metadata)</li> <li><code>GET /api/map/cuts/:id/locations</code> (locations with addresses)</li> <li><code>GET /api/map/cuts/:id/statistics</code> (aggregated statistics)</li> </ul> </li> <li>Sets <code>cut</code>, <code>addresses</code>, <code>stats</code> states</li> <li> <p>Sets <code>loading</code> to <code>false</code></p> </li> <li> <p>Address Flattening:</p> </li> <li>API returns locations with nested addresses array</li> <li>Component flattens to single <code>AddressWithLocation[]</code> array: <pre><code>const flatAddresses: AddressWithLocation[] = [];\nfor (const loc of locations) {\n if (loc.addresses && loc.addresses.length > 0) {\n for (const addr of loc.addresses) {\n flatAddresses.push({\n ...addr,\n locationAddress: loc.address, // Add parent location address\n });\n }\n }\n}\n</code></pre></li> <li> <p>Result: One row per address (not per location)</p> </li> <li> <p>Statistics Rendering:</p> </li> <li><code>stats.total</code> \u2192 Total card</li> <li><code>stats.byLevel.LEVEL_1</code> \u2192 Strong card</li> <li><code>stats.byLevel.LEVEL_2</code> \u2192 Likely card</li> <li><code>stats.byLevel.LEVEL_3</code> \u2192 Unsure card</li> <li><code>stats.byLevel.LEVEL_4</code> \u2192 Oppose card</li> <li><code>stats.byLevel.NONE</code> \u2192 None card</li> <li><code>stats.withSign</code> \u2192 Signs card</li> <li> <p>Count emails/phones from <code>addresses</code> array</p> </li> <li> <p>User Clicks Print:</p> </li> <li><code>window.print()</code> called</li> <li>Browser opens print dialog</li> <li>Print CSS rules activate</li> <li>Report rendered in landscape layout</li> </ol>"},{"location":"v2/frontend/pages/admin/cut-export-page/#api-integration","title":"API Integration","text":""},{"location":"v2/frontend/pages/admin/cut-export-page/#endpoints-used","title":"Endpoints Used","text":"<ol> <li>GET /api/map/cuts/:id - Fetch cut metadata (name, category, assignedTo)</li> <li>GET /api/map/cuts/:id/locations - Fetch all locations within cut (with nested addresses)</li> <li>GET /api/map/cuts/:id/statistics - Fetch aggregated cut statistics (support levels, signs)</li> </ol>"},{"location":"v2/frontend/pages/admin/cut-export-page/#api-client","title":"API Client","text":"<pre><code>import { api } from '@/lib/api';\n\n// All requests use authenticated API client with automatic token refresh\n</code></pre>"},{"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":"<pre><code>const cutRes = await api.get<Cut>(`/map/cuts/${id}`);\nsetCut(cutRes.data);\n</code></pre> <p>Response Format: <pre><code>{\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</code></pre></p>"},{"location":"v2/frontend/pages/admin/cut-export-page/#2-fetch-locations-with-addresses","title":"2. Fetch Locations with Addresses","text":"<pre><code>const locsRes = await api.get<Location[]>(`/map/cuts/${id}/locations`);\nconst locations = locsRes.data;\n</code></pre> <p>Response Format: <pre><code>[\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</code></pre></p>"},{"location":"v2/frontend/pages/admin/cut-export-page/#3-fetch-cut-statistics","title":"3. Fetch Cut Statistics","text":"<pre><code>const statsRes = await api.get<CutStatistics>(`/map/cuts/${id}/statistics`);\nsetStats(statsRes.data);\n</code></pre> <p>Response Format: <pre><code>{\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</code></pre></p>"},{"location":"v2/frontend/pages/admin/cut-export-page/#4-parallel-api-calls-pattern","title":"4. Parallel API Calls Pattern","text":"<pre><code>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</code></pre> <p>Benefit: Parallel requests reduce total loading time (3 requests in ~200ms instead of ~600ms sequential).</p>"},{"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":"<pre><code>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</code></pre> <p>Result: - Input: 100 locations with 2-10 addresses each - Output: 500 flat addresses (one row per address in table)</p>"},{"location":"v2/frontend/pages/admin/cut-export-page/#statistics-grid-rendering","title":"Statistics Grid Rendering","text":"<pre><code><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</code></pre>"},{"location":"v2/frontend/pages/admin/cut-export-page/#support-level-tag-rendering","title":"Support Level Tag Rendering","text":"<pre><code>{\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</code></pre>"},{"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":"<p>Three API calls made in parallel with <code>Promise.all()</code>:</p> <pre><code>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</code></pre> <p>Benefit: Total loading time ~200ms (slowest request) instead of ~600ms (sum of all requests).</p>"},{"location":"v2/frontend/pages/admin/cut-export-page/#2-address-flattening-onm","title":"2. Address Flattening (O(n*m))","text":"<p>Flattening addresses is O(n\u00d7m) where n = locations, m = addresses per location:</p> <pre><code>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</code></pre> <p>Complexity: O(n\u00d7m), typically O(100\u00d75) = O(500) operations</p> <p>Benefit: Simple nested loop, fast for typical cut sizes (< 1000 addresses).</p>"},{"location":"v2/frontend/pages/admin/cut-export-page/#3-no-pagination-print-all","title":"3. No Pagination (Print All)","text":"<p>Table has <code>pagination={false}</code>:</p> <pre><code><Table\n dataSource={addresses}\n pagination={false} // Print all addresses\n/>\n</code></pre> <p>Trade-off: - Benefit: All addresses visible in one print job (no manual page-turning) - Cost: Large cuts (> 500 addresses) may slow page load slightly</p> <p>Rationale: Printable reports typically exported for offline use, so full dataset preferred over pagination.</p>"},{"location":"v2/frontend/pages/admin/cut-export-page/#4-usememo-for-header-actions","title":"4. useMemo for Header Actions","text":"<p>Header actions memoized with <code>useMemo</code> to prevent re-renders:</p> <pre><code>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</code></pre> <p>Benefit: Header actions only recreated if <code>navigate</code> changes (never changes), preventing unnecessary re-renders.</p>"},{"location":"v2/frontend/pages/admin/cut-export-page/#responsive-design","title":"Responsive Design","text":""},{"location":"v2/frontend/pages/admin/cut-export-page/#desktop-first-layout","title":"Desktop-First Layout","text":"<p>Report optimized for desktop printing, not mobile viewing: - Landscape orientation (<code>@page { size: letter landscape; }</code>) - 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)</p>"},{"location":"v2/frontend/pages/admin/cut-export-page/#responsive-statistics-grid","title":"Responsive Statistics Grid","text":"<pre><code><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</code></pre> <p>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)</p>"},{"location":"v2/frontend/pages/admin/cut-export-page/#print-layout-landscape","title":"Print Layout (Landscape)","text":"<pre><code>@page {\n size: letter landscape;\n margin: 0.25in;\n}\n</code></pre> <p>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</p>"},{"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":"<p>Cut export report is primarily for printing, not interactive use. Accessibility considerations minimal:</p> <ol> <li>Semantic HTML:</li> <li><code><table></code> for address grid</li> <li><code><th></code> for column headers</li> <li><code><td></code> for data cells</li> <li> <p>Proper heading hierarchy (<code><h4></code> for cut name)</p> </li> <li> <p>Keyboard Navigation:</p> </li> <li>\"Back to Cuts\" button accessible via Tab + Enter</li> <li> <p>\"Print\" button accessible via Tab + Enter</p> </li> <li> <p>Screen Reader Support:</p> </li> <li>Table headers announced for each column</li> <li>Statistics titles read before values</li> <li> <p>Tag labels announced (e.g., \"Strong Support\", \"Priority Cut\")</p> </li> <li> <p>Color Contrast:</p> </li> <li>Support level tags meet WCAG AA standards</li> <li>Statistics value colors have sufficient contrast on white background</li> </ol> <p>Note: Once printed, report relies on visual layout (table, colors, spacing) for interpretation.</p>"},{"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":"<p>Symptoms: - Navigate to <code>/app/map/cuts/:id/export</code> - Page shows error: \"Cut not found\" - No data displayed</p> <p>Causes: 1. Invalid cut ID in URL (cut doesn't exist) 2. API returned 404 for cut 3. Cut deleted after URL generated</p> <p>Solutions:</p> <ol> <li>Verify cut ID:</li> <li>Check URL bar: <code>/app/map/cuts/5/export</code></li> <li>Note the ID number (5)</li> <li>Navigate to \"Map\" \u2192 \"Cuts\"</li> <li> <p>Verify cut with ID 5 exists in table</p> </li> <li> <p>Check API response:</p> </li> <li>Open browser DevTools (F12)</li> <li>Go to Network tab</li> <li>Look for <code>GET /api/map/cuts/5</code> request</li> <li> <p>Check response:</p> <ul> <li>200 OK: Cut exists, check response body</li> <li>404 Not Found: Cut doesn't exist</li> <li>500 Server Error: API error</li> </ul> </li> <li> <p>Navigate from Cuts page:</p> </li> <li>Instead of typing URL manually, click \"Export\" button from CutsPage</li> <li>This ensures valid cut ID used</li> </ol>"},{"location":"v2/frontend/pages/admin/cut-export-page/#problem-address-table-is-empty","title":"Problem: Address Table is Empty","text":"<p>Symptoms: - Cut metadata loads correctly (name, category, assigned person) - Statistics cards show zeros - Address table has no rows</p> <p>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</p> <p>Solutions:</p> <ol> <li>Check cut has locations:</li> <li>Navigate to \"Map\" \u2192 \"Cuts\"</li> <li>Click on cut name to view details</li> <li>Check \"Locations\" count in details modal</li> <li> <p>If 0 locations, cut is empty (no addresses to export)</p> </li> <li> <p>Assign locations to cut:</p> </li> <li>Navigate to \"Map\" \u2192 \"Locations\"</li> <li>Draw cut polygon on map</li> <li>Use point-in-polygon to assign locations</li> <li> <p>Re-export cut</p> </li> <li> <p>Check locations have addresses:</p> </li> <li>Navigate to \"Map\" \u2192 \"Locations\"</li> <li>Click on location in cut</li> <li>Check \"Addresses\" tab in location details</li> <li> <p>If no addresses, add address records via CSV import or manual entry</p> </li> <li> <p>Check API response:</p> </li> <li>Open browser DevTools (F12)</li> <li>Go to Network tab</li> <li>Look for <code>GET /api/map/cuts/:id/locations</code> request</li> <li>Check response:<ul> <li>200 OK with empty array <code>[]</code>: No locations in cut</li> <li>200 OK with locations but no <code>addresses</code> field: Locations exist but no address data</li> <li>500 Server Error: API error</li> </ul> </li> </ol>"},{"location":"v2/frontend/pages/admin/cut-export-page/#problem-statistics-dont-match-address-table","title":"Problem: Statistics Don't Match Address Table","text":"<p>Symptoms: - Statistics cards show different counts than visible in address table - Example: \"Strong\" card shows 80, but table has 60 \"Strong\" tags</p> <p>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</p> <p>Solutions:</p> <ol> <li>Verify statistics API:</li> <li>Statistics endpoint (<code>/api/map/cuts/:id/statistics</code>) counts ALL addresses</li> <li>Table may filter addresses (e.g., only show addresses with names)</li> <li> <p>This is expected behavior</p> </li> <li> <p>Check table filters:</p> </li> <li>No default filters applied in CutExportPage</li> <li> <p>If custom filters added, they may hide addresses from table</p> </li> <li> <p>Refresh page:</p> </li> <li>Hard refresh (Ctrl+Shift+R or Cmd+Shift+R)</li> <li> <p>Clears cached data and re-fetches from API</p> </li> <li> <p>Check API responses match:</p> </li> <li>Open browser DevTools (F12)</li> <li>Go to Network tab</li> <li>Compare responses:<ul> <li><code>GET /api/map/cuts/:id/statistics</code> \u2192 total: 150</li> <li><code>GET /api/map/cuts/:id/locations</code> \u2192 count addresses in response</li> <li>Counts should match</li> </ul> </li> </ol>"},{"location":"v2/frontend/pages/admin/cut-export-page/#problem-print-preview-is-blank","title":"Problem: Print Preview is Blank","text":"<p>Symptoms: - Click \"Print\" button - Print preview shows blank page - No content visible</p> <p>Causes: 1. Print CSS not applying 2. Browser print settings incorrect 3. Content outside printable area</p> <p>Solutions:</p> <ol> <li>Check print CSS:</li> <li>View page source (Ctrl+U or Cmd+U)</li> <li>Verify <code><style></code> tag with <code>@media print</code> rules exists</li> <li> <p>If missing, print CSS not loaded</p> </li> <li> <p>Enable background graphics:</p> </li> <li>In print dialog, check \"Background graphics\" option</li> <li> <p>This ensures table borders and colors print</p> </li> <li> <p>Try different browser:</p> </li> <li>Chrome, Firefox, and Edge have different print engines</li> <li> <p>If one fails, try another</p> </li> <li> <p>Check browser console:</p> </li> <li>Open DevTools (F12)</li> <li>Go to Console tab</li> <li> <p>Look for CSS errors (e.g., invalid print rules)</p> </li> <li> <p>Use Ctrl+P instead of button:</p> </li> <li>Press <code>Ctrl+P</code> (Windows/Linux) or <code>Cmd+P</code> (Mac)</li> <li>This bypasses custom print button and uses browser default</li> </ol>"},{"location":"v2/frontend/pages/admin/cut-export-page/#problem-table-columns-cut-off-when-printed","title":"Problem: Table Columns Cut Off When Printed","text":"<p>Symptoms: - Print preview shows table, but rightmost columns missing - Horizontal scrollbar visible in print preview</p> <p>Causes: 1. Portrait orientation used instead of landscape 2. Table too wide for paper 3. Print scaling set to \"Fit to page\" (shrinks content)</p> <p>Solutions:</p> <ol> <li>Verify landscape orientation:</li> <li>In print dialog, check \"Orientation: Landscape\"</li> <li>Landscape gives 11\" width instead of 8.5\"</li> <li> <p>Critical for 8-column table</p> </li> <li> <p>Check print scaling:</p> </li> <li>In print dialog, set scale to \"100%\" (not \"Fit to page\")</li> <li> <p>\"Fit to page\" shrinks content, making text too small</p> </li> <li> <p>Reduce font sizes:</p> </li> <li> <p>If table still too wide, edit print CSS: <pre><code>@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</code></pre></p> </li> <li> <p>Remove less important columns:</p> </li> <li>Temporarily hide \"Notes\" column (least critical for field use): <pre><code>const columns = [\n // ... other columns ...\n // Comment out Notes column\n // { title: 'Notes', dataIndex: 'notes', ... },\n];\n</code></pre></li> </ol>"},{"location":"v2/frontend/pages/admin/cut-export-page/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/frontend/pages/admin/cut-export-page/#backend-documentation","title":"Backend Documentation","text":"<ul> <li>Cuts Module - Cut CRUD API + spatial queries</li> <li>Locations Module - Location + Address CRUD</li> <li>Cut Statistics Service - Aggregation logic</li> </ul>"},{"location":"v2/frontend/pages/admin/cut-export-page/#frontend-documentation","title":"Frontend Documentation","text":"<ul> <li>CutsPage - Cut management table with export button</li> <li>WalkSheetPage - Printable walk sheet form (complementary printable page)</li> <li>LocationsPage - Location + Address CRUD</li> </ul>"},{"location":"v2/frontend/pages/admin/cut-export-page/#feature-documentation","title":"Feature Documentation","text":"<ul> <li>Canvassing System - Complete volunteer canvassing workflow</li> <li>Cut Management - Creating and managing geographic boundaries</li> <li>Report Generation - Printable reports for campaigns</li> </ul>"},{"location":"v2/frontend/pages/admin/cut-export-page/#api-documentation","title":"API Documentation","text":"<ul> <li>GET /api/map/cuts/:id - Fetch cut metadata</li> <li>GET /api/map/cuts/:id/locations - Fetch locations in cut</li> <li>GET /api/map/cuts/:id/statistics - Fetch cut statistics</li> </ul>"},{"location":"v2/frontend/pages/admin/cut-export-page/#user-guides","title":"User Guides","text":"<ul> <li>Campaign Organizer Guide - Using cut reports for planning</li> <li>Field Organizer Guide - Printing and using reports during canvassing</li> </ul>"},{"location":"v2/frontend/pages/admin/cut-export-page/#deployment-documentation","title":"Deployment Documentation","text":"<ul> <li>Printing Best Practices - Print server configuration for campaign offices</li> </ul>"},{"location":"v2/frontend/pages/admin/cuts-page/","title":"CutsPage","text":""},{"location":"v2/frontend/pages/admin/cuts-page/#overview","title":"Overview","text":"<p>The CutsPage provides administrative management of geographic polygon boundaries (\"cuts\") used to organize canvassing territories for volunteer door-knocking campaigns. It offers a dual-view interface: a table view for CRUD operations on cut metadata, and an interactive map view for drawing new polygons, editing existing boundaries, and visualizing all cuts simultaneously. The page integrates with the Location system and Shift system to enable territory-based volunteer assignments.</p> <p>Route: <code>/app/map/cuts</code> Component: <code>admin/src/pages/CutsPage.tsx</code> (561 lines) Auth Required: Yes (SUPER_ADMIN or MAP_ADMIN role recommended) Layout: AppLayout Backend Module: <code>api/src/modules/map/cuts/</code></p>"},{"location":"v2/frontend/pages/admin/cuts-page/#screenshot","title":"Screenshot","text":"<p>[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.]</p>"},{"location":"v2/frontend/pages/admin/cuts-page/#features","title":"Features","text":"<ul> <li>Dual-view interface \u2014 Segmented control to switch between table and map views</li> <li>Cut CRUD operations \u2014 Create, read, update, delete cut metadata (name, description, color)</li> <li>Interactive polygon drawing \u2014 Click vertices on map to draw custom boundaries</li> <li>Cut visualization \u2014 View all cuts as colored polygon overlays on map</li> <li>GeoJSON import/export \u2014 Import cuts from GeoJSON files, export to GeoJSON format</li> <li>Location assignment \u2014 Automatically assign locations within polygon boundaries</li> <li>Color-coded polygons \u2014 Each cut has customizable color for visual distinction</li> <li>Location count badges \u2014 See number of locations within each cut at a glance</li> <li>Search and filter \u2014 Search cuts by name or description (300ms debounce)</li> <li>Sortable table \u2014 Sort by name, location count, or creation date</li> <li>Polygon editing \u2014 Edit existing cut boundaries on map (drag vertices, add/remove points)</li> <li>Validation \u2014 Prevent self-intersecting polygons, ensure minimum 3 vertices</li> <li>Responsive design \u2014 Mobile-friendly table, full-screen map view</li> <li>Pagination \u2014 Configurable page size (10, 25, 50, 100 per page)</li> </ul>"},{"location":"v2/frontend/pages/admin/cuts-page/#user-workflow","title":"User Workflow","text":""},{"location":"v2/frontend/pages/admin/cuts-page/#creating-a-new-cut-table-view","title":"Creating a New Cut (Table View)","text":"<ol> <li>Navigate to <code>/app/map/cuts</code></li> <li>Ensure \"Table\" tab is selected (default)</li> <li>Click \"Create Cut\" button (top right)</li> <li>Modal appears: \"Create Cut\"</li> <li>Fill in fields:</li> <li>Name: (required) e.g., \"Downtown Core\"</li> <li>Description: (optional) e.g., \"High-density residential area with apartment buildings\"</li> <li>Color: (required) Click color picker to select polygon color (default: #3498db blue)</li> <li>Click \"Create\" button</li> <li>Success message: \"Cut created successfully\"</li> <li>Modal closes, table refreshes to show new cut (with 0 locations initially)</li> <li>New cut appears in table with selected color preview circle</li> </ol>"},{"location":"v2/frontend/pages/admin/cuts-page/#drawing-a-cut-polygon-map-view","title":"Drawing a Cut Polygon (Map View)","text":"<ol> <li>Click \"Map\" tab in Segmented control</li> <li>Map view appears with CutEditorMap component</li> <li>Existing cuts render as colored polygon overlays</li> <li>Click \"Draw New Cut\" button (top-left map controls)</li> <li>Drawing mode activates:</li> <li>Cursor changes to crosshair</li> <li>Instructional text: \"Click to place vertices. Double-click or click first vertex to close polygon.\"</li> <li>Click map to place first vertex (blue circle marker appears)</li> <li>Click again to place second vertex (line drawn between vertices)</li> <li>Continue clicking to place vertices (polygon outline forms)</li> <li>Close polygon by:</li> <li>Double-clicking final vertex, OR</li> <li>Clicking first vertex again (close detection radius: 10 pixels)</li> <li>Polygon closes automatically, fills with semi-transparent color</li> <li>Modal appears: \"Save Cut\"</li> <li>Fill in fields:<ul> <li>Name: (required) e.g., \"Riverside District\"</li> <li>Description: (optional) e.g., \"Area bounded by river and highway\"</li> <li>Color: (required, pre-filled with default blue)</li> </ul> </li> <li>Click \"Save\" button</li> <li>Backend calculates locations within polygon (point-in-polygon algorithm)</li> <li>Success message: \"Cut created successfully with 47 locations\"</li> <li>Polygon remains on map, now saved to database</li> <li>Switch to \"Table\" tab to see new cut with location count: 47</li> </ol>"},{"location":"v2/frontend/pages/admin/cuts-page/#editing-a-cut","title":"Editing a Cut","text":"<ol> <li>In Table view, locate cut to edit</li> <li>Click \"Edit\" button in Actions column</li> <li>Modal appears: \"Edit Cut\"</li> <li>Modify fields:</li> <li>Name: Update cut name</li> <li>Description: Update or add description</li> <li>Color: Change polygon color (affects map visualization)</li> <li>Click \"Save\" button</li> <li>Success message: \"Cut updated successfully\"</li> <li>Table refreshes to show updated values</li> <li>Color preview circle updates to new color</li> </ol> <p>Note: Editing cut metadata does NOT modify polygon boundary. To change boundary, must delete cut and redraw.</p>"},{"location":"v2/frontend/pages/admin/cuts-page/#importing-cuts-from-geojson","title":"Importing Cuts from GeoJSON","text":"<ol> <li>In Table view, click \"Import GeoJSON\" button (top right, next to Create Cut)</li> <li>File picker opens</li> <li>Select GeoJSON file from local filesystem (e.g., <code>cuts-export-2026-01-15.geojson</code>)</li> <li>File uploads to backend</li> <li>Backend parses GeoJSON:</li> <li>Validates FeatureCollection format</li> <li>Extracts polygons from features</li> <li>Creates Cut records with properties (name, description, color)</li> <li>Calculates locations within each polygon</li> <li>Success message: \"Imported 5 cuts with 234 total locations\"</li> <li>Table refreshes to show imported cuts</li> <li>Switch to Map view to see imported polygons</li> </ol> <p>GeoJSON Format Expected:</p> <pre><code>{\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</code></pre>"},{"location":"v2/frontend/pages/admin/cuts-page/#exporting-a-cut-to-geojson","title":"Exporting a Cut to GeoJSON","text":"<ol> <li>In Table view, locate cut to export</li> <li>Click \"Export GeoJSON\" button in Actions column</li> <li>Backend generates GeoJSON with:</li> <li>Polygon geometry (coordinates array)</li> <li>Cut properties (name, description, color)</li> <li>Location count metadata</li> <li>Browser downloads file: <code>cut-{name}-{id}.geojson</code></li> <li>File can be opened in GIS software (QGIS, ArcGIS) or re-imported later</li> </ol> <p>Example Export:</p> <pre><code>{\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</code></pre>"},{"location":"v2/frontend/pages/admin/cuts-page/#viewing-locations-in-a-cut","title":"Viewing Locations in a Cut","text":"<ol> <li>In Table view, locate cut</li> <li>Note location count badge (e.g., \"47 locations\")</li> <li>Click \"View Locations\" button in Actions column</li> <li>Navigates to <code>/app/map/locations?cutId={cutId}</code></li> <li>LocationsPage opens with cut filter pre-applied</li> <li>Table shows only locations within that cut's polygon</li> <li>Can edit locations, view on map, or export to CSV</li> </ol>"},{"location":"v2/frontend/pages/admin/cuts-page/#deleting-a-cut","title":"Deleting a Cut","text":"<ol> <li>In Table view, locate cut to delete</li> <li>Click \"Delete\" button in Actions column (red text)</li> <li>Confirmation modal appears: \"Are you sure you want to delete the cut 'Downtown Core'? This will unassign all locations from this cut but will not delete the locations themselves.\"</li> <li>Click \"Delete\" to confirm (or \"Cancel\" to abort)</li> <li>Backend:</li> <li>Deletes Cut record from database</li> <li>Sets <code>cutId = null</code> on all Location records within polygon (unassigns)</li> <li>Deletes associated Shift records (shifts are cut-specific)</li> <li>Success message: \"Cut deleted successfully. 47 locations unassigned.\"</li> <li>Table refreshes, deleted cut removed</li> <li>Switch to Map view: polygon no longer visible</li> </ol>"},{"location":"v2/frontend/pages/admin/cuts-page/#searching-cuts","title":"Searching Cuts","text":"<ol> <li>Locate search bar at top of Table view (below Segmented control)</li> <li>Start typing search query (e.g., \"Downtown\")</li> <li>Search automatically triggers after 300ms pause (debounce)</li> <li>Table filters to show matching cuts</li> <li>Matches on: cut name, description</li> <li>Clear search by clicking X icon or deleting text</li> </ol>"},{"location":"v2/frontend/pages/admin/cuts-page/#sorting-the-table","title":"Sorting the Table","text":"<ol> <li>Identify sortable columns (Name, Location Count, Created At)</li> <li>Click Name column header to sort alphabetically (A\u2192Z)</li> <li>Click again to reverse sort (Z\u2192A)</li> <li>Click Location Count to sort by number of locations (ascending)</li> <li>Click again to reverse sort (descending, highest first)</li> <li>Click Created At to sort by creation date (newest first)</li> <li>Can combine with search filter (sorted results only)</li> </ol>"},{"location":"v2/frontend/pages/admin/cuts-page/#component-breakdown","title":"Component Breakdown","text":""},{"location":"v2/frontend/pages/admin/cuts-page/#ant-design-components-used","title":"Ant Design Components Used","text":"<ul> <li>Typography.Title \u2014 Page heading (\"Cuts\")</li> <li>Typography.Text \u2014 Labels, descriptions, empty state text</li> <li>Segmented \u2014 Tab control for table/map view switching (large button style)</li> <li>Space \u2014 Button grouping (Create, Import)</li> <li>Button \u2014 Primary actions (Create, Import, Draw), row actions (Edit, View, Export, Delete)</li> <li>Input.Search \u2014 Cut search field with debounce</li> <li>Table \u2014 Main data table with sortable columns, pagination</li> <li>Tag \u2014 Location count badges</li> <li>Modal \u2014 Create/edit cut form, confirmation dialogs</li> <li>Form \u2014 Cut metadata form (name, description, color)</li> <li>Form.Item \u2014 Form field wrapper with validation</li> <li>Input \u2014 Text fields (name, description)</li> <li>Input.TextArea \u2014 Description field (multi-line)</li> <li>ColorPicker \u2014 Cut color selection</li> <li>Upload \u2014 GeoJSON file upload</li> <li>message \u2014 Toast notifications for success/error feedback</li> <li>Empty \u2014 Empty state when no cuts exist</li> </ul>"},{"location":"v2/frontend/pages/admin/cuts-page/#map-components-custom","title":"Map Components (Custom)","text":"<ul> <li>CutEditorMap \u2014 Specialized Leaflet map wrapper for cut drawing/editing</li> <li>Renders existing cuts as polygon overlays</li> <li>Provides drawing mode for new polygons</li> <li>Handles vertex placement, line drawing, polygon closing</li> <li>Validates polygon geometry (minimum 3 vertices, no self-intersections)</li> <li> <p>Provides save callback after polygon closes</p> </li> <li> <p>CutOverlays \u2014 Component for rendering cut polygons on map</p> </li> <li>Renders Leaflet Polygon layers for each cut</li> <li>Applies cut color to fill and stroke</li> <li>Adds tooltip on hover (cut name + location count)</li> <li>Handles click events for cut selection</li> </ul>"},{"location":"v2/frontend/pages/admin/cuts-page/#segmented-tab-control","title":"Segmented Tab Control","text":"<pre><code><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</code></pre> <p>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)</p>"},{"location":"v2/frontend/pages/admin/cuts-page/#table-structure","title":"Table Structure","text":"<pre><code>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</code></pre> <p>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</p>"},{"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":"<pre><code>// 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</code></pre> <p>No Global State:</p> <p>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</p>"},{"location":"v2/frontend/pages/admin/cuts-page/#debounced-search-pattern","title":"Debounced Search Pattern","text":"<pre><code>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</code></pre> <p>Why 300ms Debounce?</p> <ul> <li>Performance: Prevents API call on every keystroke</li> <li>User Experience: Long enough to avoid lag, short enough to feel responsive</li> <li>API Load: Reduces backend database queries</li> <li>Reset pagination: Search resets to page 1 (user expects to see first results)</li> </ul>"},{"location":"v2/frontend/pages/admin/cuts-page/#usecallback-optimization","title":"useCallback Optimization","text":"<pre><code>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</code></pre> <p>Conditional Loading:</p> <p>Cuts only load when Table tab is active. Map view uses separate data fetching in CutEditorMap component.</p>"},{"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 <code>/api/cuts</code> List cuts (paginated, filtered) Required GET <code>/api/cuts/:id</code> Get single cut with geometry Required POST <code>/api/cuts</code> Create new cut Required PUT <code>/api/cuts/:id</code> Update cut metadata Required DELETE <code>/api/cuts/:id</code> Delete cut Required POST <code>/api/cuts/import</code> Import cuts from GeoJSON Required GET <code>/api/cuts/:id/export</code> Export cut to GeoJSON Required"},{"location":"v2/frontend/pages/admin/cuts-page/#load-cuts-paginated-with-search","title":"Load Cuts (Paginated with Search)","text":"<p>Request:</p> <pre><code>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</code></pre> <p>Query Parameters: - <code>page</code> (number, required): Page number (1-indexed) - <code>limit</code> (number, required): Items per page (10, 25, 50, or 100) - <code>search</code> (string, optional): Search query (matches name, description)</p> <p>Response (200 OK):</p> <pre><code>{\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</code></pre> <p>Response Fields:</p> <ul> <li><code>id</code> (string): Unique cut identifier (prefixed with \"cut_\")</li> <li><code>name</code> (string): Cut name</li> <li><code>description</code> (string | null): Optional description</li> <li><code>color</code> (string): Hex color code (e.g., \"#3498db\")</li> <li><code>geometry</code> (GeoJSON Polygon): Polygon boundary (GeoJSON format)</li> <li><code>locationCount</code> (number): Number of locations within polygon (calculated field, not stored)</li> <li><code>createdAt</code> (ISO 8601): Creation timestamp</li> <li><code>updatedAt</code> (ISO 8601): Last update timestamp</li> </ul>"},{"location":"v2/frontend/pages/admin/cuts-page/#create-cut","title":"Create Cut","text":"<p>Request:</p> <pre><code>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</code></pre> <p>Request Body Schema:</p> <pre><code>{\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</code></pre> <p>Validation Rules:</p> <ul> <li>Name: Required, 1-255 characters</li> <li>Description: Optional, max 1000 characters</li> <li>Color: Required, must be valid hex color (#RRGGBB format)</li> <li>Geometry: Required, must be valid GeoJSON Polygon</li> <li>Minimum 3 vertices (4 coordinates including closing point)</li> <li>Closing point must match first point (polygon must be closed)</li> <li>No self-intersections (validated by backend)</li> <li>Coordinates in [longitude, latitude] order (GeoJSON standard)</li> </ul> <p>Response (201 Created):</p> <pre><code>{\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</code></pre> <p>Backend Workflow:</p> <pre><code>// 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</code></pre>"},{"location":"v2/frontend/pages/admin/cuts-page/#update-cut-metadata","title":"Update Cut Metadata","text":"<p>Request:</p> <pre><code>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</code></pre> <p>Request Body Schema:</p> <pre><code>{\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</code></pre> <p>Response (200 OK):</p> <pre><code>{\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</code></pre> <p>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).</p>"},{"location":"v2/frontend/pages/admin/cuts-page/#delete-cut","title":"Delete Cut","text":"<p>Request:</p> <pre><code>const cutId = 'cut_abc123';\nawait api.delete(`/cuts/${cutId}`);\n</code></pre> <p>URL Parameter: - <code>id</code> (string): Cut ID to delete</p> <p>Response (200 OK):</p> <pre><code>{\n \"message\": \"Cut deleted successfully\",\n \"unassignedLocations\": 47\n}\n</code></pre> <p>Response Fields: - <code>message</code> (string): Confirmation message - <code>unassignedLocations</code> (number): Number of locations that were unassigned from cut</p> <p>Backend Workflow:</p> <pre><code>// 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</code></pre> <p>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)</p>"},{"location":"v2/frontend/pages/admin/cuts-page/#import-cuts-from-geojson","title":"Import Cuts from GeoJSON","text":"<p>Request:</p> <pre><code>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</code></pre> <p>Request Body: - <code>file</code> (File): GeoJSON file (FeatureCollection with Polygon features)</p> <p>Expected GeoJSON Format:</p> <pre><code>{\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</code></pre> <p>Response (200 OK):</p> <pre><code>{\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</code></pre> <p>Error Response (400 Bad Request) - Invalid GeoJSON:</p> <pre><code>{\n \"error\": \"Validation Error\",\n \"message\": \"Invalid GeoJSON format. Expected FeatureCollection with Polygon features.\"\n}\n</code></pre> <p>Backend Workflow:</p> <pre><code>// 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</code></pre>"},{"location":"v2/frontend/pages/admin/cuts-page/#export-cut-to-geojson","title":"Export Cut to GeoJSON","text":"<p>Request:</p> <pre><code>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</code></pre> <p>Response (200 OK):</p> <pre><code>{\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</code></pre> <p>GeoJSON Feature Structure: - <code>type</code>: \"Feature\" (GeoJSON standard) - <code>geometry</code>: Polygon geometry with coordinates - <code>properties</code>: Cut metadata including location count, timestamps</p>"},{"location":"v2/frontend/pages/admin/cuts-page/#code-examples","title":"Code Examples","text":""},{"location":"v2/frontend/pages/admin/cuts-page/#complete-cut-creation-flow-map-view","title":"Complete Cut Creation Flow (Map View)","text":"<pre><code>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</code></pre> <p>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</p>"},{"location":"v2/frontend/pages/admin/cuts-page/#geojson-import-flow","title":"GeoJSON Import Flow","text":"<pre><code>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</code></pre> <p>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)</p>"},{"location":"v2/frontend/pages/admin/cuts-page/#geojson-export-flow","title":"GeoJSON Export Flow","text":"<pre><code>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</code></pre> <p>File Naming: - Pattern: <code>cut-{name}-{id}.geojson</code> - Example: <code>cut-downtown-core-cut_abc123.geojson</code> - Spaces in name replaced with hyphens - Lowercase for consistency</p>"},{"location":"v2/frontend/pages/admin/cuts-page/#delete-with-cascade-warning","title":"Delete with Cascade Warning","text":"<pre><code>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</code></pre> <p>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</p>"},{"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":"<p>Map view only loads when tab is active:</p> <pre><code>useEffect(() => {\n if (activeTab === 'map') {\n // Load cuts for map visualization\n loadCutsForMap();\n }\n}, [activeTab]);\n</code></pre> <p>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</p>"},{"location":"v2/frontend/pages/admin/cuts-page/#server-side-pagination","title":"Server-Side Pagination","text":"<p>Table uses server-side pagination to handle large cut datasets:</p> <pre><code>const { data } = await api.get('/cuts', {\n params: {\n page: pagination.current,\n limit: pagination.pageSize,\n search,\n },\n});\n</code></pre> <p>Scalability: - Works efficiently with 10 to 1,000+ cuts - Only fetches current page (10-100 items) - Backend applies search filter before pagination</p>"},{"location":"v2/frontend/pages/admin/cuts-page/#debounced-search-300ms","title":"Debounced Search (300ms)","text":"<p>Prevents API spam during typing:</p> <pre><code>searchTimerRef.current = setTimeout(() => {\n setSearch(value);\n}, 300);\n</code></pre> <p>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</p>"},{"location":"v2/frontend/pages/admin/cuts-page/#polygon-simplification-future-enhancement","title":"Polygon Simplification (Future Enhancement)","text":"<p>For cuts with 1,000+ vertices (very detailed polygons), consider simplifying geometry:</p> <pre><code>// 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</code></pre> <p>Benefits: - Reduces GeoJSON payload size - Faster map rendering (fewer vertices to draw) - Maintains visual accuracy for canvassing purposes</p>"},{"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":"<p>Table adapts to mobile viewports:</p> <pre><code>{\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</code></pre> <p>Mobile Columns (xs): - Name (visible) - Color (visible) - Actions (visible, wrapped)</p> <p>Tablet Columns (sm+): - Name + Color + Location Count + Actions</p> <p>Desktop Columns (md+): - Name + Description + Color + Location Count + Created At + Actions</p>"},{"location":"v2/frontend/pages/admin/cuts-page/#full-screen-map-view","title":"Full-Screen Map View","text":"<p>Map view uses full available height:</p> <pre><code><div style={{ height: 'calc(100vh - 200px)', width: '100%' }}>\n <CutEditorMap\n cuts={cuts}\n onSaveCut={handleSaveCut}\n />\n</div>\n</code></pre> <p>Calculation: - <code>100vh</code>: Full viewport height - <code>-200px</code>: Subtract header (64px) + page title (48px) + segmented control (48px) + margins (40px) - Result: Map fills remaining vertical space</p>"},{"location":"v2/frontend/pages/admin/cuts-page/#accessibility","title":"Accessibility","text":""},{"location":"v2/frontend/pages/admin/cuts-page/#keyboard-navigation","title":"Keyboard Navigation","text":"<p>All interactive elements are keyboard-accessible:</p> <p>Segmented Control: - Tab: Focus on segmented control - Arrow Keys: Switch between Table and Map tabs - Enter/Space: Activate selected tab</p> <p>Table Navigation: - Tab: Move between action buttons (Edit, View, Export, Delete) - Enter/Space: Activate focused button - Arrow Keys: Navigate table rows</p> <p>Map Drawing: - Escape: Cancel drawing mode - Enter: Complete polygon (after placing 3+ vertices) - Backspace: Remove last vertex</p>"},{"location":"v2/frontend/pages/admin/cuts-page/#screen-reader-support","title":"Screen Reader Support","text":"<p>All elements have proper ARIA labels:</p> <p>Action Buttons: <pre><code><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</code></pre></p> <p>Color Preview: <pre><code><div\n style={{ backgroundColor: cut.color }}\n aria-label={`Cut color: ${cut.color}`}\n role=\"img\"\n/>\n</code></pre></p>"},{"location":"v2/frontend/pages/admin/cuts-page/#focus-indicators","title":"Focus Indicators","text":"<p>All interactive elements have visible focus states:</p> <p>Buttons: <pre><code>.ant-btn:focus {\n outline: 2px solid #1890ff;\n outline-offset: 2px;\n}\n</code></pre></p> <p>Segmented Control: <pre><code>.ant-segmented-item:focus {\n outline: 2px solid #1890ff;\n outline-offset: 2px;\n}\n</code></pre></p>"},{"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":"<p>Problem: Drawing polygon on map, clicked 5+ vertices, but polygon won't close automatically.</p> <p>Diagnosis:</p> <p>Check if first vertex is being clicked:</p> <pre><code>// 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</code></pre> <p>Possible Causes:</p> <ol> <li>Click not close enough to first vertex:</li> <li>Must click within 10 pixels of first vertex marker</li> <li> <p>First vertex marker may be small or obscured</p> </li> <li> <p>Double-click required:</p> </li> <li>Some users expect double-click to close polygon</li> <li> <p>Single-click on first vertex should work but may feel unintuitive</p> </li> <li> <p>Drawing mode not active:</p> </li> <li>Forgot to click \"Draw New Cut\" button first</li> <li>Drawing mode indicator not visible</li> </ol> <p>Solution:</p> <ol> <li>For close detection:</li> <li>Click directly on the blue circle marker (first vertex)</li> <li>Or double-click anywhere to force close polygon</li> <li> <p>Ensure at least 3 vertices placed before closing</p> </li> <li> <p>Alternative closing methods:</p> </li> <li>Press Enter key to close polygon (keyboard shortcut)</li> <li> <p>Right-click and select \"Close Polygon\" from context menu (if implemented)</p> </li> <li> <p>Visual feedback:</p> </li> <li>First vertex marker should pulse or highlight when hovering nearby (indicates close detection active)</li> <li>Drawing mode indicator should show \"Click first vertex to close\" text</li> </ol>"},{"location":"v2/frontend/pages/admin/cuts-page/#import-geojson-fails","title":"Import GeoJSON Fails","text":"<p>Problem: Click \"Import GeoJSON\", select file, get error: \"Invalid GeoJSON format\".</p> <p>Diagnosis:</p> <p>Check GeoJSON structure:</p> <pre><code>// 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</code></pre> <p>Possible Causes:</p> <ol> <li>Single Feature instead of FeatureCollection:</li> <li>GeoJSON file contains single Feature, not FeatureCollection</li> <li> <p>Backend expects FeatureCollection with multiple features</p> </li> <li> <p>Non-Polygon geometries:</p> </li> <li>GeoJSON contains Point, LineString, or MultiPolygon features</li> <li> <p>Backend only supports Polygon geometry type</p> </li> <li> <p>Missing required properties:</p> </li> <li>Feature properties don't include \"name\" field</li> <li> <p>Backend requires name to create cut</p> </li> <li> <p>Invalid JSON syntax:</p> </li> <li>Trailing commas, missing quotes, incorrect brackets</li> <li>JSON parser cannot read file</li> </ol> <p>Solution:</p> <ol> <li>For single Feature:</li> <li> <p>Wrap in FeatureCollection: <pre><code>{\n \"type\": \"FeatureCollection\",\n \"features\": [\n {\n \"type\": \"Feature\",\n \"geometry\": {...},\n \"properties\": {...}\n }\n ]\n}\n</code></pre></p> </li> <li> <p>For non-Polygon geometries:</p> </li> <li>Convert Point/LineString to Polygon using GIS software (QGIS, ArcGIS)</li> <li> <p>Or manually edit GeoJSON to create Polygon boundaries</p> </li> <li> <p>For missing properties:</p> </li> <li> <p>Add \"name\" property to each Feature: <pre><code>\"properties\": {\n \"name\": \"Untitled Cut\",\n \"description\": \"\",\n \"color\": \"#3498db\"\n}\n</code></pre></p> </li> <li> <p>For invalid JSON:</p> </li> <li>Validate JSON syntax using online tool (jsonlint.com)</li> <li>Fix any syntax errors before importing</li> </ol>"},{"location":"v2/frontend/pages/admin/cuts-page/#locations-not-appearing-in-cut","title":"Locations Not Appearing in Cut","text":"<p>Problem: Create cut polygon, success message says \"Cut created successfully with 0 locations\", but there should be locations within boundary.</p> <p>Diagnosis:</p> <p>Check location coordinates vs. polygon coordinates:</p> <pre><code>// 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</code></pre> <p>Possible Causes:</p> <ol> <li>Coordinate order confusion:</li> <li>Location stored as [lat, lng] but polygon uses [lng, lat] (GeoJSON standard)</li> <li> <p>Point-in-polygon algorithm receives wrong coordinate order</p> </li> <li> <p>Locations not geocoded:</p> </li> <li>Locations have null latitude/longitude values</li> <li> <p>Cannot check if point is in polygon without coordinates</p> </li> <li> <p>Polygon too small:</p> </li> <li>Drew very small polygon that doesn't actually contain any location markers</li> <li> <p>Zoom in on map to verify polygon size vs. location density</p> </li> <li> <p>Precision issues:</p> </li> <li>Location coordinates have low precision (e.g., rounded to 2 decimal places)</li> <li>Polygon boundary is at edge of location, but point-in-polygon check fails due to rounding</li> </ol> <p>Solution:</p> <ol> <li>For coordinate order:</li> <li> <p>Verify backend point-in-polygon function uses correct order: <pre><code>isPointInPolygon(\n [location.longitude, location.latitude], // [lng, lat] order\n polygon.coordinates[0]\n);\n</code></pre></p> </li> <li> <p>For missing coordinates:</p> </li> <li>Run geocoding on locations before assigning to cut</li> <li> <p>Navigate to LocationsPage, bulk geocode locations, then create cut</p> </li> <li> <p>For small polygons:</p> </li> <li>Zoom in on map to see location markers</li> <li>Draw larger polygon that clearly encompasses location clusters</li> <li> <p>Use Location Count filter on LocationsPage to verify locations exist in area</p> </li> <li> <p>For precision issues:</p> </li> <li>Use higher precision coordinates (6+ decimal places = ~0.1 meter accuracy)</li> <li>Slightly expand polygon boundary to account for rounding errors</li> </ol>"},{"location":"v2/frontend/pages/admin/cuts-page/#delete-cut-fails-with-constraint-error","title":"Delete Cut Fails with Constraint Error","text":"<p>Problem: Click \"Delete\" button, confirm deletion, get error: \"Failed to delete cut. Constraint violation.\"</p> <p>Diagnosis:</p> <p>Check database foreign key constraints:</p> <pre><code>-- Check for references to cut\nSELECT COUNT(*) FROM \"Shift\" WHERE \"cutId\" = 'cut_abc123';\nSELECT COUNT(*) FROM \"CanvassSession\" WHERE \"cutId\" = 'cut_abc123';\n</code></pre> <p>Possible Causes:</p> <ol> <li>Active shifts:</li> <li>Shift records reference this cutId</li> <li> <p>Foreign key constraint prevents deletion</p> </li> <li> <p>Active canvass sessions:</p> </li> <li>CanvassSession records reference this cutId</li> <li> <p>Sessions must be closed/deleted before cut can be deleted</p> </li> <li> <p>Database migration issue:</p> </li> <li>Foreign key constraints not set to CASCADE</li> <li>Deletion of parent record (Cut) should cascade to child records (Shift, CanvassSession)</li> </ol> <p>Solution:</p> <ol> <li>For active shifts:</li> <li>Navigate to <code>/app/map/shifts</code></li> <li>Filter by cut name</li> <li>Delete all shifts in this cut</li> <li> <p>Return to CutsPage and retry delete</p> </li> <li> <p>For active sessions:</p> </li> <li>Navigate to <code>/app/canvass/dashboard</code></li> <li>Find active sessions in this cut</li> <li>Close or abandon sessions</li> <li> <p>Return to CutsPage and retry delete</p> </li> <li> <p>For migration issue (developer fix):</p> </li> <li>Update Prisma schema to add cascade delete: <pre><code>model Shift {\n cutId String?\n cut Cut? @relation(fields: [cutId], references: [id], onDelete: Cascade)\n}\n\nmodel CanvassSession {\n cutId String?\n cut Cut? @relation(fields: [cutId], references: [id], onDelete: Cascade)\n}\n</code></pre></li> <li>Run migration: <code>npx prisma migrate dev</code></li> </ol>"},{"location":"v2/frontend/pages/admin/cuts-page/#related-documentation","title":"Related Documentation","text":"<ul> <li>Map Module Overview \u2014 Geographic mapping feature set</li> <li>Cuts Backend Module \u2014 Backend cut service and API</li> <li>Cuts API Reference \u2014 API endpoint documentation</li> <li>Spatial Utils \u2014 Point-in-polygon algorithm</li> <li>LocationsPage \u2014 Location management (references cuts)</li> <li>ShiftsPage \u2014 Shift scheduling (references cuts)</li> <li>Canvass Dashboard \u2014 Canvassing overview (uses cuts)</li> <li>CutEditorMap Component \u2014 Map drawing component</li> <li>User Guide: Map Organizer \u2014 Cut management workflow</li> <li>GeoJSON Specification \u2014 GeoJSON format reference</li> <li>Troubleshooting: GIS Issues \u2014 Geographic data troubleshooting</li> </ul>"},{"location":"v2/frontend/pages/admin/dashboard-page/","title":"DashboardPage","text":""},{"location":"v2/frontend/pages/admin/dashboard-page/#overview","title":"Overview","text":"<p>The DashboardPage serves as the landing page for authenticated admin users after login. It provides a high-level overview of key metrics across all modules (Users, Campaigns, Locations, Emails) using statistic cards. Currently displays placeholder values with a notice that full analytics are coming soon.</p> <p>Route: <code>/app</code> Component: <code>admin/src/pages/DashboardPage.tsx</code> (67 lines) Auth Required: Yes (any authenticated admin role) Layout: AppLayout</p>"},{"location":"v2/frontend/pages/admin/dashboard-page/#screenshot","title":"Screenshot","text":"<p>[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.]</p>"},{"location":"v2/frontend/pages/admin/dashboard-page/#features","title":"Features","text":"<ul> <li>Personalized greeting \u2014 Shows \"Welcome, [User Name]\" if user has a name set</li> <li>Quick metrics overview \u2014 4 statistic cards with icons:</li> <li>Total Users (TeamOutlined icon)</li> <li>Active Campaigns (SendOutlined icon)</li> <li>Map Locations (EnvironmentOutlined icon)</li> <li>Emails Sent (MailOutlined icon)</li> <li>Responsive grid layout \u2014 Cards adapt to screen size (xs: full width, sm: 2 columns, lg: 4 columns)</li> <li>Placeholder state \u2014 Currently shows \"--\" for all metrics with info alert</li> <li>Future-ready \u2014 Structure prepared for real-time statistics integration</li> </ul>"},{"location":"v2/frontend/pages/admin/dashboard-page/#user-workflow","title":"User Workflow","text":""},{"location":"v2/frontend/pages/admin/dashboard-page/#viewing-dashboard-current-state","title":"Viewing Dashboard (Current State)","text":"<ol> <li>User logs in and is redirected to <code>/app</code></li> <li>Dashboard loads with personalized greeting</li> <li>Four metric cards display with placeholder values (\"--\")</li> <li>Info alert explains that analytics are coming soon</li> </ol>"},{"location":"v2/frontend/pages/admin/dashboard-page/#planned-workflow-future-enhancement","title":"Planned Workflow (Future Enhancement)","text":"<ol> <li>User logs in and is redirected to <code>/app</code></li> <li>Dashboard fetches real-time statistics from API</li> <li>Metric cards populate with actual values</li> <li>Charts and graphs display below cards (planned)</li> <li>Recent activity feed shows latest actions (planned)</li> </ol>"},{"location":"v2/frontend/pages/admin/dashboard-page/#component-breakdown","title":"Component Breakdown","text":""},{"location":"v2/frontend/pages/admin/dashboard-page/#ant-design-components-used","title":"Ant Design Components Used","text":"<ul> <li>Typography.Title \u2014 Page title with greeting</li> <li>Row, Col \u2014 Responsive grid layout</li> <li>Row with gutter: <code>[16, 16]</code> (horizontal, vertical)</li> <li>Col breakpoints: <code>xs={24} sm={12} lg={6}</code> (responsive card sizing)</li> <li>Card \u2014 Container for each statistic</li> <li>Statistic \u2014 Numeric display with title, value, prefix icon</li> <li>Alert \u2014 Info message about future analytics</li> <li>Icons \u2014 Ant Design icons for visual clarity</li> <li>TeamOutlined (users)</li> <li>SendOutlined (campaigns)</li> <li>EnvironmentOutlined (locations)</li> <li>MailOutlined (emails)</li> </ul>"},{"location":"v2/frontend/pages/admin/dashboard-page/#component-structure","title":"Component Structure","text":"<pre><code><>\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</code></pre>"},{"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":"<ul> <li>auth.store \u2014 Accesses current user data</li> <li><code>user</code> \u2014 User object with <code>name</code> field for personalized greeting</li> </ul> <pre><code>import { useAuthStore } from '@/stores/auth.store';\n\nconst { user } = useAuthStore();\n</code></pre>"},{"location":"v2/frontend/pages/admin/dashboard-page/#local-state","title":"Local State","text":"<p>None \u2014 Component is stateless, reads user from auth store only.</p>"},{"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":"<p>No API calls \u2014 displays placeholder values.</p>"},{"location":"v2/frontend/pages/admin/dashboard-page/#planned-api-integration","title":"Planned API Integration","text":"<p>GET /api/dashboard/stats \u2014 Fetch dashboard statistics</p> <p>Planned request: <pre><code>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</code></pre></p>"},{"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":"<pre><code>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</code></pre>"},{"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":"<ul> <li>Cards stack vertically (full width)</li> <li>Greeting text wraps naturally</li> <li>Alert description wraps</li> </ul>"},{"location":"v2/frontend/pages/admin/dashboard-page/#tablet-576px-992px","title":"Tablet (576px - 992px)","text":"<ul> <li>Cards display 2 per row</li> <li>Even spacing maintained</li> </ul>"},{"location":"v2/frontend/pages/admin/dashboard-page/#desktop-992px","title":"Desktop (\u2265 992px)","text":"<ul> <li>Cards display 4 per row</li> <li>Optimal for wide screens</li> <li>All content visible without scrolling</li> </ul>"},{"location":"v2/frontend/pages/admin/dashboard-page/#accessibility","title":"Accessibility","text":"<ul> <li>Semantic HTML \u2014 Proper heading hierarchy (h4 for title)</li> <li>Icon labels \u2014 Statistic titles provide text alternative to icons</li> <li>Color contrast \u2014 Default Ant Design theme ensures WCAG AA compliance</li> <li>Keyboard navigation \u2014 All interactive elements focusable</li> </ul>"},{"location":"v2/frontend/pages/admin/dashboard-page/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/frontend/pages/admin/dashboard-page/#current-performance","title":"Current Performance","text":"<ul> <li>Fast initial render \u2014 No API calls, minimal DOM</li> <li>Small bundle \u2014 Only imports necessary Ant Design components</li> <li>No re-renders \u2014 Stateless, no local state changes</li> </ul>"},{"location":"v2/frontend/pages/admin/dashboard-page/#planned-optimizations","title":"Planned Optimizations","text":"<ul> <li>Memoization \u2014 Use <code>useMemo</code> for derived stats</li> <li>Caching \u2014 Cache dashboard stats with 5-minute expiry</li> <li>Skeleton loading \u2014 Show loading skeleton during fetch</li> </ul> <pre><code>import { Skeleton } from 'antd';\n\n{loading ? (\n <Card>\n <Skeleton.Input active size=\"small\" style={{ width: 100 }} />\n <Skeleton.Input active size=\"large\" style={{ width: 60, marginTop: 8 }} />\n </Card>\n) : (\n <Card>\n <Statistic title=\"Total Users\" value={stats.totalUsers} />\n </Card>\n)}\n</code></pre>"},{"location":"v2/frontend/pages/admin/dashboard-page/#future-enhancements","title":"Future Enhancements","text":"<ol> <li>Real-time statistics \u2014 WebSocket updates for live metrics</li> <li>Charts and graphs \u2014 Trend visualizations (Chart.js or Recharts)</li> <li>Recent activity feed \u2014 List of latest actions across all modules</li> <li>Quick actions \u2014 Buttons for common tasks (Create Campaign, Add User, etc.)</li> <li>Module-specific widgets \u2014 Expandable cards with detailed stats</li> <li>Date range filter \u2014 View metrics for custom time periods</li> <li>Export dashboard \u2014 PDF report generation</li> </ol>"},{"location":"v2/frontend/pages/admin/dashboard-page/#related-documentation","title":"Related Documentation","text":"<ul> <li>Auth Module \u2014 Authentication system</li> <li>Users Module \u2014 User statistics</li> <li>Campaigns Module \u2014 Campaign statistics</li> <li>Locations Module \u2014 Location statistics</li> <li>AppLayout Component \u2014 Layout wrapper</li> <li>Auth Store \u2014 Authentication state</li> </ul>"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/","title":"DataQualityDashboardPage","text":""},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#overview","title":"Overview","text":"<p>File: <code>admin/src/pages/DataQualityDashboardPage.tsx</code> Route: <code>/app/map/data-quality</code> Role Requirements: <code>SUPER_ADMIN</code>, <code>MAP_ADMIN</code></p> <p>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.</p> <p>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)</p> <p>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</p> <p>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</p>"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#screenshot","title":"Screenshot","text":"<p>[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.]</p>"},{"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":"<ol> <li>Overview Statistics (4 cards)</li> <li>Total Locations: Count of all locations in database (blue)</li> <li>Geocoded: Count + percentage of geocoded locations (green)</li> <li>Ungeocoded: Count of locations without coordinates (red if > 0, gray if 0)</li> <li> <p>Average Confidence: Percentage score with color-coded threshold (green \u226585%, yellow 60-84%, red <60%)</p> </li> <li> <p>Geocoding Confidence Breakdown (4 cards)</p> </li> <li>High Confidence: Count of locations with \u226585% confidence (green)</li> <li>Medium Confidence: Count of locations with 60-84% confidence (yellow)</li> <li>Low Confidence: Count of locations with <60% confidence (red)</li> <li> <p>Manual/None: Count of locations with no confidence score (gray)</p> </li> <li> <p>Provider Distribution (Dynamic cards)</p> </li> <li>One card per geocoding provider (Nominatim, ArcGIS, Photon, Google, Manual, etc.)</li> <li>Capitalized provider names</li> <li>Count of locations geocoded by each provider</li> <li> <p>Dynamic grid layout (adapts to number of providers)</p> </li> <li> <p>Building Type Distribution (4 cards)</p> </li> <li>Single Family: Count of single-family residences (blue)</li> <li>Multi-Unit: Count of multi-unit residential buildings (green)</li> <li>Mixed Use: Count of mixed-use properties (yellow)</li> <li> <p>Commercial: Count of commercial properties (purple)</p> </li> <li> <p>Auto-Refresh</p> </li> <li>Refreshes every 30 seconds automatically</li> <li>Interval set with <code>setInterval</code>, cleaned up on unmount</li> <li> <p>No loading spinner on auto-refresh (seamless updates)</p> </li> <li> <p>Manual Refresh</p> </li> <li>Refresh button in page header</li> <li>Loading state during refresh</li> <li> <p>Fetches latest statistics from API</p> </li> <li> <p>Responsive Grid Layout</p> </li> <li>Desktop (\u2265768px): 4 columns per row</li> <li>Tablet (\u2265576px): 2 columns per row</li> <li>Mobile (<576px): 1 column per row</li> <li> <p>Consistent gap (16px horizontal, 16px vertical)</p> </li> <li> <p>Color-Coded Statistics</p> </li> <li>Green (#52c41a): Good/high confidence/geocoded</li> <li>Red (#ff4d4f): Warning/low confidence/ungeocoded</li> <li>Yellow (#faad14): Medium confidence</li> <li>Blue (#1890ff): Neutral/informational</li> <li>Gray (#8c8c8c): Neutral/none</li> <li>Purple (#722ed1): Commercial building type</li> </ol>"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#user-workflow","title":"User Workflow","text":""},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#viewing-data-quality-overview","title":"Viewing Data Quality Overview","text":"<ol> <li>Navigate to page: Admin sidebar \u2192 Map \u2192 Data Quality</li> <li>Page loads: Initial statistics fetched and displayed</li> <li>Review overview cards:</li> <li>Check total locations count</li> <li>Verify geocoded percentage (aim for > 90%)</li> <li>Check if any ungeocoded locations (red warning)</li> <li>Review average confidence score (aim for \u226585%)</li> </ol>"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#interpreting-confidence-levels","title":"Interpreting Confidence Levels","text":"<p>High Confidence (\u226585%): - Indicates accurate geocoding with precise coordinates - Green color = good data quality - Goal: Most locations should be in this category</p> <p>Medium Confidence (60-84%): - Indicates acceptable geocoding but less precise - Yellow color = acceptable but could improve - Consider manual review or re-geocoding</p> <p>Low Confidence (<60%): - Indicates poor geocoding accuracy - Red color = data quality issue - Action: Re-geocode with different provider or manually verify</p> <p>Manual/None: - Manually entered coordinates or no confidence score - Gray color = neutral (may be accurate if manually verified)</p>"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#monitoring-provider-performance","title":"Monitoring Provider Performance","text":"<ol> <li>Check provider distribution cards:</li> <li>See which providers are most used</li> <li>Identify dominant provider (e.g., Nominatim: 8000, Google: 2000)</li> <li>Correlate with confidence levels:</li> <li>If high confidence count matches dominant provider, good sign</li> <li>If low confidence count high, may indicate provider issues</li> <li>Consider provider switching:</li> <li>Use LocationsPage to re-geocode low-confidence locations with different provider</li> </ol>"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#reviewing-building-types","title":"Reviewing Building Types","text":"<ol> <li>Check building type distribution:</li> <li>Single Family: Residential detached homes</li> <li>Multi-Unit: Apartments, condos, duplexes</li> <li>Mixed Use: Residential + commercial combo</li> <li>Commercial: Stores, offices, warehouses</li> <li>Verify data accuracy:</li> <li>Ensure building types match expected distribution for your area</li> <li>Flag anomalies (e.g., 0 single-family homes in suburban area)</li> </ol>"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#using-auto-refresh","title":"Using Auto-Refresh","text":"<ol> <li>Leave page open: Auto-refresh updates data every 30 seconds</li> <li>Monitor changes: Watch for new locations being added/geocoded</li> <li>No action needed: Data updates seamlessly without loading spinners</li> </ol>"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#manual-refresh","title":"Manual Refresh","text":"<ol> <li>Click Refresh button: In page header</li> <li>Loading state: Brief spinner or loading indicator</li> <li>Data updates: Latest statistics fetched from API</li> <li>Use case: Immediate update after bulk geocoding operation</li> </ol>"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#component-breakdown","title":"Component Breakdown","text":""},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#overview-statistics-cards","title":"Overview Statistics Cards","text":"<pre><code><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</code></pre> <p>Responsive Grid: - <code>xs={24}</code>: Mobile (full width) - <code>sm={12}</code>: Tablet (2 columns, 50% width each) - <code>md={6}</code>: Desktop (4 columns, 25% width each)</p>"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#confidence-level-cards","title":"Confidence Level Cards","text":"<pre><code><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</code></pre>"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#provider-distribution-cards","title":"Provider Distribution Cards","text":"<pre><code><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</code></pre> <p>Dynamic Grid: Number of cards adapts to number of providers in response.</p>"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#building-type-cards","title":"Building Type Cards","text":"<pre><code><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</code></pre>"},{"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":"<pre><code>const [stats, setStats] = useState<LocationStats | null>(null);\nconst [loading, setLoading] = useState(true);\n</code></pre>"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#data-fetching","title":"Data Fetching","text":"<pre><code>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</code></pre>"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#auto-refresh-setup","title":"Auto-Refresh Setup","text":"<pre><code>useEffect(() => {\n loadStats(); // Initial load\n const interval = setInterval(loadStats, 30000); // Refresh every 30s\n return () => clearInterval(interval); // Cleanup on unmount\n}, [loadStats]);\n</code></pre> <p>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)</p>"},{"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":"<p>GET <code>/map/locations/stats</code> - Fetch location statistics <pre><code>const { data } = await api.get<LocationStats>('/map/locations/stats');\n</code></pre></p> <p>Response: <pre><code>{\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</code></pre></p>"},{"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":"<pre><code><Text type=\"secondary\" style={{ fontSize: 12 }}>\n ({stats.total > 0 ? Math.round((stats.geocoded / stats.total) * 100) : 0}%)\n</Text>\n</code></pre> <p>Pattern: Round percentage to nearest integer, handle division by zero.</p>"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#conditional-color","title":"Conditional Color","text":"<pre><code>valueStyle={{\n color:\n !stats.confidence.average ? '#8c8c8c'\n : stats.confidence.average >= 85 ? '#52c41a'\n : stats.confidence.average >= 60 ? '#faad14'\n : '#ff4d4f',\n}}\n</code></pre> <p>Color Logic: - No average \u2192 Gray - \u226585% \u2192 Green - 60-84% \u2192 Yellow - <60% \u2192 Red</p>"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#conditional-icon","title":"Conditional Icon","text":"<pre><code>prefix={stats.ungeocoded > 0 ? <WarningOutlined /> : undefined}\n</code></pre> <p>Pattern: Show warning icon only if ungeocoded count > 0.</p>"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#auto-refresh-pattern","title":"Auto-Refresh Pattern","text":"<pre><code>useEffect(() => {\n loadStats();\n const interval = setInterval(loadStats, 30000);\n return () => clearInterval(interval);\n}, [loadStats]);\n</code></pre> <p>Best Practice: Always clean up intervals to prevent memory leaks.</p>"},{"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":"<pre><code>return () => clearInterval(interval);\n</code></pre> <p>Why Important: Without cleanup, interval continues running after component unmounts, causing memory leaks and unnecessary API calls.</p>"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#usecallback-for-load-function","title":"useCallback for Load Function","text":"<pre><code>const loadStats = useCallback(async () => { /* ... */ }, [message]);\n</code></pre> <p>Why: Prevents recreation of load function on every render, essential for stable <code>useEffect</code> dependency.</p>"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#efficient-percentage-calculation","title":"Efficient Percentage Calculation","text":"<pre><code>Math.round((stats.geocoded / stats.total) * 100)\n</code></pre> <p>Math.round() is more efficient than <code>.toFixed()</code> for integer percentages.</p>"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#responsive-design","title":"Responsive Design","text":""},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#responsive-grid","title":"Responsive Grid","text":"<pre><code><Col xs={24} sm={12} md={6}>\n</code></pre> <p>Breakpoints: - Mobile (<code>xs</code>, < 576px): 24/24 = 100% width (1 column) - Tablet (<code>sm</code>, \u2265 576px): 12/24 = 50% width (2 columns) - Desktop (<code>md</code>, \u2265 768px): 6/24 = 25% width (4 columns)</p>"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#padding-adjustment","title":"Padding Adjustment","text":"<pre><code><div style={{ padding: screens.md ? 24 : 16 }}>\n</code></pre> <p>Reduces padding on mobile to maximize screen space.</p>"},{"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":"<pre><code><Statistic title=\"Total Locations\" />\n</code></pre> <p>Clear, descriptive titles for each metric.</p>"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#icon-text-combination","title":"Icon + Text Combination","text":"<pre><code><Statistic\n prefix={<EnvironmentOutlined />}\n value={stats.total}\n/>\n</code></pre> <p>Icons enhance visual communication but text labels provide meaning.</p>"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#suffix-explanations","title":"Suffix Explanations","text":"<pre><code><Statistic\n suffix={<Text type=\"secondary\">\u226585%</Text>}\n/>\n</code></pre> <p>Explains threshold for confidence levels.</p>"},{"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":"<p>Symptoms: - Loading spinner forever - Error message \"Failed to load data quality stats\"</p> <p>Causes: 1. API server down 2. Database connection issue 3. Permission denied</p> <p>Solutions: <pre><code># 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</code></pre></p>"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#percentage-shows-0-but-locations-exist","title":"Percentage Shows 0% (but locations exist)","text":"<p>Cause: All locations ungeocoded (<code>stats.geocoded === 0</code>)</p> <p>Expected Behavior: Percentage correctly shows 0% (not a bug)</p> <p>Solution: Geocode locations using LocationsPage bulk geocoding feature.</p>"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#average-confidence-shows-0","title":"Average Confidence Shows 0%","text":"<p>Cause: No geocoded locations have confidence scores</p> <p>Expected Behavior: Shows 0% and gray color</p> <p>Solution: Confidence scores only populated during geocoding. Re-geocode locations to populate confidence.</p>"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#auto-refresh-not-working","title":"Auto-Refresh Not Working","text":"<p>Symptoms: - Statistics never update automatically - Must manually click Refresh button</p> <p>Causes: 1. Component unmounting and remounting (React Strict Mode in dev) 2. Interval cleared prematurely 3. Browser tab inactive (browser throttles timers)</p> <p>Debug: <pre><code>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</code></pre></p>"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#building-types-show-unexpected-zeros","title":"Building Types Show Unexpected Zeros","text":"<p>Cause: Building type not set for locations (optional field)</p> <p>Expected Behavior: <code>buildingTypes</code> counts only locations with <code>buildingType</code> set</p> <p>Solution: Update locations to set <code>buildingType</code> field (use LocationsPage).</p>"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#backend-integration","title":"Backend Integration","text":"<ul> <li>Locations Module - Service, schemas, routes</li> <li>Locations API Reference - Full endpoint documentation</li> </ul>"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#frontend-pages","title":"Frontend Pages","text":"<ul> <li>LocationsPage - Location CRUD and geocoding operations</li> </ul>"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#features_1","title":"Features","text":"<ul> <li>Geocoding System - Multi-provider geocoding</li> <li>Location Management - Location data management</li> </ul>"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#user-guides","title":"User Guides","text":"<ul> <li>Admin Guide - Data Quality - Data quality workflows</li> <li>Map Organizer Guide - Location management</li> </ul>"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#troubleshooting_1","title":"Troubleshooting","text":"<ul> <li>Geocoding Issues - Geocoding troubleshooting</li> <li>Database Issues - Database troubleshooting</li> </ul>"},{"location":"v2/frontend/pages/admin/data-quality-dashboard-page/#related-pages","title":"Related Pages","text":"<ul> <li>LocationsPage - Bulk geocoding operations</li> <li>MapSettingsPage - Map configuration</li> </ul>"},{"location":"v2/frontend/pages/admin/docs-page/","title":"DocsPage","text":""},{"location":"v2/frontend/pages/admin/docs-page/#overview","title":"Overview","text":"<p>File: <code>admin/src/pages/DocsPage.tsx</code> Route: <code>/app/docs</code> Role Requirements: <code>SUPER_ADMIN</code></p> <p>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.</p> <p>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.</p> <p>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)</p> <p>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</p>"},{"location":"v2/frontend/pages/admin/docs-page/#screenshot","title":"Screenshot","text":"<p>[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.]</p>"},{"location":"v2/frontend/pages/admin/docs-page/#features","title":"Features","text":""},{"location":"v2/frontend/pages/admin/docs-page/#core-features","title":"Core Features","text":"<ol> <li>Three-Panel Resizable Layout</li> <li>Left: File tree (160px - 400px width, draggable divider)</li> <li>Center: Monaco editor (40% width default, draggable)</li> <li>Right: MkDocs preview iframe (60% width default, draggable)</li> <li>Layout mode switcher: Split / Editor-only / Preview-only</li> <li>Collapsible tree panel (click hamburger or thin bar to toggle)</li> <li> <p>Persists layout preferences in localStorage</p> </li> <li> <p>File Tree Browser</p> </li> <li>Hierarchical file/folder display (Ant Design Tree)</li> <li>Shows .md files without extension (e.g., \"index\" not \"index.md\")</li> <li>Tight spacing (28px row height) for compact view</li> <li>Smooth hover effects (rgba background transitions)</li> <li>Selected file highlighted</li> <li>Expand/collapse folders with arrow icons</li> <li>Context menu (right-click) for New File, New Folder, Rename, Delete</li> <li> <p>Search/filter with auto-expand matching nodes</p> </li> <li> <p>Monaco Code Editor</p> </li> <li>Markdown syntax highlighting with VS Dark theme</li> <li>Line numbers, word wrap, no minimap (clean editing)</li> <li>Real-time change detection (dirty state tracking)</li> <li>Ctrl+S keyboard shortcut for saving</li> <li>Custom right-click context menu (replaces Monaco's default)</li> <li> <p>Detects file type (markdown, yaml, json, css, html, javascript)</p> </li> <li> <p>MkDocs Snippet System (60+ snippets)</p> </li> <li>Formatting: Bold (**), Italic (*), Strikethrough (~~), Highlight (==), Inline Code (`), Keyboard Key (++)</li> <li>Headings: H1-H4 with # syntax</li> <li>Admonitions: Note, Warning, Tip, Danger, Info, Success, Question, Abstract, Example, Bug, Quote (+ collapsible variants)</li> <li>Code: Code block (```), Annotated code, Mermaid diagrams</li> <li>Insert: Link, Image, Button, Primary button, Material icon, Table, Task list, Tabs, Math block, Footnote, Definition list, Horizontal rule</li> <li> <p>Snippet types: wrap (surround selection), block (insert template), insert (paste content)</p> </li> <li> <p>Formatting Toolbar</p> </li> <li>Always visible for .md files (28px height, compact)</li> <li>Direct buttons: Bold, Italic, Strikethrough, Highlight, Inline Code, Keyboard Key</li> <li>Dropdown menus: Headings (H1-H4), Admonitions (11 types + collapsible), Code (3 types), Insert (12 elements)</li> <li> <p>Keyboard shortcuts shown in menus (Ctrl+B, Ctrl+I)</p> </li> <li> <p>Live Preview</p> </li> <li>MkDocs server iframe (proxied via <code>/mkdocs-proxy/</code>)</li> <li>Auto-reload on save (500ms delay)</li> <li>Manual refresh button in toolbar</li> <li>URL preview bar above iframe showing production + localhost URLs</li> <li> <p>Click URL buttons to open in new tab</p> </li> <li> <p>File Operations</p> </li> <li>New File: Right-click folder \u2192 New File (auto-appends .md)</li> <li>New Folder: Right-click folder \u2192 New Folder</li> <li>Rename: Right-click file/folder \u2192 Rename</li> <li>Delete: Right-click file/folder \u2192 Delete (with confirmation modal)</li> <li> <p>Root-level creation: Toolbar buttons (+ File, + Folder icons)</p> </li> <li> <p>File Tree Actions</p> </li> <li>Filter: Search button in tree toolbar \u2192 input field \u2192 auto-expand matches</li> <li>Expand All: Button in tree toolbar</li> <li>Collapse All: Button in tree toolbar</li> <li>Hide Panel: Fold icon collapses tree to thin bar</li> <li> <p>Show Panel: Click thin bar or unfold icon to restore tree</p> </li> <li> <p>Save Operations</p> </li> <li>Save button in top toolbar (blue primary, shows when dirty)</li> <li>Ctrl+S keyboard shortcut (global)</li> <li>Loading state during save</li> <li>Success message on save</li> <li> <p>Modified indicator in editor status bar</p> </li> <li> <p>Layout Modes</p> <ul> <li>Split: Editor + Preview side-by-side (default)</li> <li>Editor: Editor only (full width)</li> <li>Preview: Preview only (full width)</li> <li>Toggle buttons in top toolbar</li> <li>Persists preference in localStorage</li> </ul> </li> <li> <p>MkDocs Site Building (SUPER_ADMIN)</p> <ul> <li>Build button in toolbar (hammer icon)</li> <li>Confirmation modal before build</li> <li>Triggers static site generation</li> <li>Success/error messages</li> </ul> </li> <li> <p>Mobile Detection</p> <ul> <li>Screens < 768px show \"Desktop Required\" message</li> <li>No editor on mobile (unusable)</li> </ul> </li> </ol>"},{"location":"v2/frontend/pages/admin/docs-page/#user-workflow","title":"User Workflow","text":""},{"location":"v2/frontend/pages/admin/docs-page/#opening-editor","title":"Opening Editor","text":"<ol> <li>Navigate to page: Admin sidebar \u2192 System \u2192 Documentation</li> <li>Page loads: File tree appears on left, empty editor in center, MkDocs homepage in preview</li> <li>Select file: Click on file in tree (e.g., <code>index.md</code>)</li> <li>Editor loads: Monaco editor shows file content, preview updates to matching page</li> </ol>"},{"location":"v2/frontend/pages/admin/docs-page/#editing-documentation","title":"Editing Documentation","text":"<ol> <li>Modify content: Type in Monaco editor (Markdown syntax)</li> <li>Use formatting toolbar: Click Bold/Italic/etc. buttons or dropdown menus</li> <li>Insert snippets: Click Insert dropdown \u2192 select Link/Image/Table/etc.</li> <li>Check preview: Right pane shows live rendering</li> <li>Save changes: Click Save button or press Ctrl+S</li> <li>Auto-refresh: Preview reloads after 500ms delay</li> </ol>"},{"location":"v2/frontend/pages/admin/docs-page/#using-formatting-toolbar","title":"Using Formatting Toolbar","text":"<p>Direct Buttons (wrap selected text): 1. Bold: Select text, click B button (or Ctrl+B) \u2192 <code>**text**</code> 2. Italic: Select text, click I button (or Ctrl+I) \u2192 <code>*text*</code> 3. Strikethrough: Select text, click S\u0336 button \u2192 <code>~~text~~</code> 4. Highlight: Select text, click highlight button \u2192 <code>==text==</code> 5. Inline Code: Select text, click <code><></code> button \u2192 <code>`text`</code> 6. Keyboard Key: Select text, click K button \u2192 <code>++text++</code></p> <p>Dropdown Menus (insert templates): 1. Headings: Click \"H \u25bc\" \u2192 select H1/H2/H3/H4 \u2192 inserts <code>##</code> at cursor 2. Admonitions: Click \"Admonitions \u25bc\" \u2192 select Note/Warning/etc. \u2192 inserts block: <pre><code>!!! note \"Title\"\n Content here\n</code></pre> 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</p>"},{"location":"v2/frontend/pages/admin/docs-page/#using-right-click-context-menu","title":"Using Right-Click Context Menu","text":"<ol> <li>Right-click in editor: Custom context menu appears (not Monaco's default)</li> <li>Select category: Formatting, Headings, Admonitions, Code, or Insert submenu</li> <li>Click snippet: Snippet applied to cursor/selection</li> <li>Context menu closes: Focus returns to editor</li> </ol> <p>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)</p>"},{"location":"v2/frontend/pages/admin/docs-page/#managing-files","title":"Managing Files","text":"<p>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., <code>my-page</code>) 4. Submit: Modal closes, new file appears in tree (auto-appends .md) 5. File auto-opens: Editor loads with template content (<code># {filename}</code>)</p> <p>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</p> <p>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</p> <p>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</p> <p>Root-Level Creation: 1. Click \"+ File\" or \"+ Folder\" icons in tree toolbar 2. Follow same modal flow as folder context menu</p>"},{"location":"v2/frontend/pages/admin/docs-page/#filtering-file-tree","title":"Filtering File Tree","text":"<ol> <li>Click search icon in tree toolbar: Filter input appears below toolbar</li> <li>Type query: Enter filename or partial match (e.g., \"api\")</li> <li>Tree filters: Only matching files/folders shown</li> <li>Matching folders auto-expand: See nested matches</li> <li>Clear filter: Click X in input or search icon to hide input</li> </ol>"},{"location":"v2/frontend/pages/admin/docs-page/#resizing-panels","title":"Resizing Panels","text":"<p>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</p> <p>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</p>"},{"location":"v2/frontend/pages/admin/docs-page/#switching-layout-modes","title":"Switching Layout Modes","text":"<ol> <li>Click layout mode button in toolbar:</li> <li>Split icon: Editor + Preview side-by-side</li> <li>Code icon: Editor only (full width)</li> <li>Eye icon: Preview only (full width)</li> <li>Layout changes immediately</li> <li>Active mode highlighted: Primary blue color</li> <li>Preference saved: Persists in localStorage</li> </ol>"},{"location":"v2/frontend/pages/admin/docs-page/#opening-preview-urls","title":"Opening Preview URLs","text":"<ol> <li>Check URL bar above preview iframe (only for .md files)</li> <li>Click \"Production\" button: Opens <code>https://docs.cmlite.org/{path}</code> in new tab</li> <li>Click \"Localhost\" button: Opens <code>http://localhost:4003/{path}</code> in new tab</li> </ol>"},{"location":"v2/frontend/pages/admin/docs-page/#building-mkdocs-site-super_admin","title":"Building MkDocs Site (SUPER_ADMIN)","text":"<ol> <li>Click Build button in toolbar (hammer icon)</li> <li>Confirmation modal: \"Build static site? This may take a few minutes.\"</li> <li>Confirm: Click OK</li> <li>Build starts: Button shows loading spinner</li> <li>Wait: ~30-60 seconds for build to complete</li> <li>Success message: \"Site built successfully\"</li> <li>Check output: Navigate to MkDocs site URL to verify</li> </ol>"},{"location":"v2/frontend/pages/admin/docs-page/#saving-and-preview-refresh","title":"Saving and Preview Refresh","text":"<ol> <li>Make changes in editor</li> <li>Status bar shows \"Modified\" in yellow</li> <li>Save with Ctrl+S or Save button</li> <li>Success message: \"Saved\"</li> <li>Preview auto-refreshes: After 500ms delay</li> <li>Status bar clears \"Modified\"</li> </ol>"},{"location":"v2/frontend/pages/admin/docs-page/#component-breakdown","title":"Component Breakdown","text":""},{"location":"v2/frontend/pages/admin/docs-page/#top-toolbar","title":"Top Toolbar","text":"<pre><code><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</code></pre>"},{"location":"v2/frontend/pages/admin/docs-page/#file-tree-component","title":"File Tree Component","text":"<pre><code><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</code></pre> <p>Tree Styling (Obsidian-style): <pre><code>.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</code></pre></p>"},{"location":"v2/frontend/pages/admin/docs-page/#monaco-editor","title":"Monaco Editor","text":"<pre><code><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</code></pre>"},{"location":"v2/frontend/pages/admin/docs-page/#formatting-toolbar","title":"Formatting Toolbar","text":"<pre><code><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</code></pre>"},{"location":"v2/frontend/pages/admin/docs-page/#url-preview-bar","title":"URL Preview Bar","text":"<pre><code>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</code></pre>"},{"location":"v2/frontend/pages/admin/docs-page/#snippet-system","title":"Snippet System","text":"<p>Snippet Definition: <pre><code>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</code></pre></p> <p>Apply Snippet Function: <pre><code>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</code></pre></p>"},{"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":"<p>File Tree & Content: <pre><code>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</code></pre></p> <p>UI State: <pre><code>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</code></pre></p> <p>Filter & Modal State: <pre><code>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</code></pre></p> <p>Monaco Refs: <pre><code>const monacoEditorRef = useRef<monacoEditor.IStandaloneCodeEditor | null>(null);\nconst monacoRef = useRef<typeof import('monaco-editor') | null>(null);\nconst previewIframeRef = useRef<HTMLIFrameElement>(null);\n</code></pre></p>"},{"location":"v2/frontend/pages/admin/docs-page/#localstorage-persistence","title":"localStorage Persistence","text":"<pre><code>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</code></pre>"},{"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":"<p>GET <code>/docs/files</code> - Fetch file tree <pre><code>const { data } = await api.get<FileNode[]>('/docs/files');\n</code></pre></p> <p>Response: <pre><code>[\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</code></pre></p> <p>GET <code>/docs/files/:filePath</code> - Read file content <pre><code>const { data } = await api.get<{ path: string; content: string }>(`/docs/files/${filePath}`);\n</code></pre></p> <p>Response: <pre><code>{\n \"path\": \"v2/index.md\",\n \"content\": \"# V2 Documentation\\n\\nWelcome to V2 docs...\"\n}\n</code></pre></p> <p>PUT <code>/docs/files/:filePath</code> - Update file <pre><code>await api.put(`/docs/files/${filePath}`, { content: fileContent });\n</code></pre></p> <p>POST <code>/docs/files/:filePath</code> - Create file/folder <pre><code>// 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</code></pre></p> <p>POST <code>/docs/files/rename</code> - Rename file/folder <pre><code>await api.post('/docs/files/rename', { from: 'old-path.md', to: 'new-path.md' });\n</code></pre></p> <p>DELETE <code>/docs/files/:filePath</code> - Delete file/folder <pre><code>await api.delete(`/docs/files/${filePath}`);\n</code></pre></p>"},{"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":"<pre><code>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</code></pre>"},{"location":"v2/frontend/pages/admin/docs-page/#drag-to-resize-logic","title":"Drag-to-Resize Logic","text":"<pre><code>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</code></pre>"},{"location":"v2/frontend/pages/admin/docs-page/#file-tree-filtering","title":"File Tree Filtering","text":"<pre><code>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</code></pre>"},{"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":"<p>Monaco loads from CDN when component mounts (not in main bundle).</p>"},{"location":"v2/frontend/pages/admin/docs-page/#usecallback-for-event-handlers","title":"useCallback for Event Handlers","text":"<p>All drag handlers, save handler, and snippet handler use <code>useCallback</code> to prevent recreation.</p>"},{"location":"v2/frontend/pages/admin/docs-page/#conditional-toolbar-rendering","title":"Conditional Toolbar Rendering","text":"<pre><code>{selectedFile?.endsWith('.md') && !fileLoading && (\n <div>{/* Formatting toolbar */}</div>\n)}\n</code></pre> <p>Only renders toolbar for Markdown files.</p>"},{"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":"<pre><code>if (isMobile) {\n return <Result status=\"info\" title=\"Desktop Required\" />;\n}\n</code></pre> <p>Screens < 768px: Show warning, don't render editor.</p>"},{"location":"v2/frontend/pages/admin/docs-page/#accessibility","title":"Accessibility","text":""},{"location":"v2/frontend/pages/admin/docs-page/#keyboard-shortcuts","title":"Keyboard Shortcuts","text":"<ul> <li>Ctrl+S: Save file</li> <li>Ctrl+B: Bold</li> <li>Ctrl+I: Italic</li> <li>Tab: Navigate through toolbar buttons, tree nodes</li> </ul>"},{"location":"v2/frontend/pages/admin/docs-page/#button-labels","title":"Button Labels","text":"<p>All toolbar buttons have tooltips with keyboard shortcuts shown.</p>"},{"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":"<p>Cause: CDN load failed or height not set</p> <p>Solution: <pre><code><Editor height=\"100%\" /> // Parent must have defined height\n</code></pre></p>"},{"location":"v2/frontend/pages/admin/docs-page/#preview-not-updating","title":"Preview Not Updating","text":"<p>Cause: Iframe src not changing or MkDocs server down</p> <p>Debug: <pre><code># Check MkDocs container\ndocker compose logs mkdocs\n\n# Restart MkDocs\ndocker compose restart mkdocs\n</code></pre></p>"},{"location":"v2/frontend/pages/admin/docs-page/#file-tree-not-loading","title":"File Tree Not Loading","text":"<p>Cause: API endpoint failing</p> <p>Debug: <pre><code># 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</code></pre></p>"},{"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":"<ul> <li>Docs Module - Docs routes and file operations</li> <li>MkDocs Configuration - MkDocs setup</li> </ul>"},{"location":"v2/frontend/pages/admin/docs-page/#features_1","title":"Features","text":"<ul> <li>Documentation System - Feature overview</li> <li>MkDocs Integration - MkDocs setup</li> </ul>"},{"location":"v2/frontend/pages/admin/docs-page/#user-guides","title":"User Guides","text":"<ul> <li>Admin Guide - Documentation - Editing workflows</li> </ul>"},{"location":"v2/frontend/pages/admin/docs-page/#external-resources","title":"External Resources","text":"<ul> <li>Monaco Editor Documentation - Monaco API</li> <li>MkDocs Documentation - MkDocs reference</li> <li>Material for MkDocs - Theme documentation</li> </ul>"},{"location":"v2/frontend/pages/admin/email-queue-page/","title":"EmailQueuePage","text":""},{"location":"v2/frontend/pages/admin/email-queue-page/#overview","title":"Overview","text":"<p>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.</p> <p>Route: <code>/app/influence/email-queue</code> Component: <code>admin/src/pages/EmailQueuePage.tsx</code> (140 lines) Auth Required: Yes (SUPER_ADMIN or INFLUENCE_ADMIN role recommended) Layout: AppLayout Backend Module: <code>api/src/modules/influence/email-queue/</code></p>"},{"location":"v2/frontend/pages/admin/email-queue-page/#screenshot","title":"Screenshot","text":"<p>[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.]</p>"},{"location":"v2/frontend/pages/admin/email-queue-page/#features","title":"Features","text":"<ul> <li>Real-time statistics \u2014 Waiting, active, completed, and failed job counts</li> <li>Auto-refresh \u2014 Updates every 10 seconds automatically</li> <li>Queue status indicator \u2014 Visual tag showing RUNNING (green) or PAUSED (orange)</li> <li>Pause/Resume control \u2014 Stop/start email processing with single button</li> <li>Clean old jobs \u2014 Remove completed jobs from queue to free memory</li> <li>Manual refresh \u2014 Force immediate statistics update</li> <li>Color-coded metrics \u2014 Semantic colors for each job state</li> <li>Minimal UI \u2014 Focus on essential monitoring data without clutter</li> <li>Header-integrated actions \u2014 Controls in page header for quick access</li> </ul>"},{"location":"v2/frontend/pages/admin/email-queue-page/#user-workflow","title":"User Workflow","text":""},{"location":"v2/frontend/pages/admin/email-queue-page/#monitoring-email-queue-health","title":"Monitoring Email Queue Health","text":"<ol> <li>Navigate to <code>/app/influence/email-queue</code></li> <li>Page loads with initial statistics fetch</li> <li>Observe statistics cards (displayed in single row):</li> <li>Waiting: Jobs queued but not yet processing (blue text)</li> <li>Active: Jobs currently being processed (green text)</li> <li>Completed: Successfully sent emails (gray text)</li> <li>Failed: Jobs that encountered errors (red text)</li> <li>Check queue status tag in header:</li> <li>RUNNING (green): Queue is processing jobs normally</li> <li>PAUSED (orange): Queue is stopped, no jobs being processed</li> <li>Auto-refresh occurs every 10 seconds:</li> <li>Statistics update silently (no loading spinner)</li> <li>Numbers increment/decrement based on queue activity</li> <li>Status tag updates if queue state changes</li> </ol>"},{"location":"v2/frontend/pages/admin/email-queue-page/#pausing-the-email-queue","title":"Pausing the Email Queue","text":"<p>When to Pause: - Troubleshooting SMTP connection issues - Performing backend maintenance - Preventing emails from sending during off-hours - Testing email configuration changes</p> <p>Steps:</p> <ol> <li>Click \"Pause\" button in header (next to Refresh button)</li> <li>API request sent to <code>/api/email-queue/pause</code></li> <li>Success message: \"Queue paused\"</li> <li>Queue status tag changes from \"RUNNING\" (green) to \"PAUSED\" (orange)</li> <li>Active count drops to 0 (currently processing jobs complete)</li> <li>Waiting count remains (jobs queued but not processing)</li> <li>Button label changes to \"Resume\"</li> </ol> <p>Effect: - No new jobs will be picked up from queue - Currently active jobs will complete (cannot interrupt mid-send) - New campaign emails will still be added to queue (just not processed)</p>"},{"location":"v2/frontend/pages/admin/email-queue-page/#resuming-the-email-queue","title":"Resuming the Email Queue","text":"<ol> <li>Verify SMTP configuration is correct (Settings page)</li> <li>Click \"Resume\" button in header</li> <li>API request sent to <code>/api/email-queue/resume</code></li> <li>Success message: \"Queue resumed\"</li> <li>Queue status tag changes from \"PAUSED\" (orange) to \"RUNNING\" (green)</li> <li>Waiting count begins decreasing as jobs are picked up</li> <li>Active count increases (workers processing jobs)</li> <li>Button label changes back to \"Pause\"</li> </ol>"},{"location":"v2/frontend/pages/admin/email-queue-page/#cleaning-old-completed-jobs","title":"Cleaning Old Completed Jobs","text":"<p>When to Clean: - Completed job count exceeds 10,000 (memory usage) - Queue dashboard feels sluggish - Regular maintenance (weekly/monthly)</p> <p>Steps:</p> <ol> <li>Click \"Clean Old Jobs\" button in header</li> <li>Confirmation: No confirmation dialog (immediate action)</li> <li>API request sent to <code>/api/email-queue/clean</code></li> <li>Backend deletes all jobs with status = COMPLETED</li> <li>Success message: \"Cleaned 1,487 completed jobs\"</li> <li>Completed count resets to 0</li> <li>Statistics automatically refresh</li> </ol> <p>Important: This only removes completed jobs. Waiting, active, and failed jobs are preserved.</p>"},{"location":"v2/frontend/pages/admin/email-queue-page/#refreshing-statistics-manually","title":"Refreshing Statistics Manually","text":"<ol> <li>Click \"Refresh\" button in header (circular arrow icon)</li> <li>Loading spinner appears on button</li> <li>API request sent to <code>/api/email-queue/stats</code></li> <li>All four statistics update simultaneously</li> <li>Loading spinner disappears</li> <li>Use case: Immediate update without waiting for 10-second auto-refresh</li> </ol>"},{"location":"v2/frontend/pages/admin/email-queue-page/#investigating-failed-jobs","title":"Investigating Failed Jobs","text":"<p>Problem: \"Failed\" count increases (e.g., from 5 to 12)</p> <p>Diagnosis Steps:</p> <ol> <li>Note current failed count (e.g., 12)</li> <li>Navigate to backend logs: <code>docker compose logs -f api | grep \"Email job failed\"</code></li> <li>Look for error messages: <pre><code>Email job failed for campaign abc123: SMTP connection timeout\n</code></pre></li> <li>Identify root cause:</li> <li>SMTP server down</li> <li>Invalid credentials</li> <li>Rate limiting</li> <li>Network connectivity issues</li> </ol> <p>Resolution:</p> <ol> <li>Fix underlying issue (e.g., update SMTP credentials in Settings)</li> <li>Return to Email Queue page</li> <li>Consider options:</li> <li>Retry failed jobs: Currently no UI button (requires backend job retry API)</li> <li>Clean failed jobs: Click \"Clean Old Jobs\" to remove (also removes completed)</li> <li>Wait for auto-retry: BullMQ will retry failed jobs automatically (3 attempts)</li> </ol>"},{"location":"v2/frontend/pages/admin/email-queue-page/#component-breakdown","title":"Component Breakdown","text":""},{"location":"v2/frontend/pages/admin/email-queue-page/#ant-design-components-used","title":"Ant Design Components Used","text":"<ul> <li>Card \u2014 Container for each statistic</li> <li>Statistic \u2014 Formatted numeric display with title</li> <li>Row / Col \u2014 Grid layout for statistics cards</li> <li>Button \u2014 Header action buttons (Refresh, Pause/Resume, Clean)</li> <li>Tag \u2014 Queue status indicator (RUNNING/PAUSED)</li> <li>Space \u2014 Button grouping in header</li> <li>message \u2014 Toast notifications for success/error feedback</li> </ul>"},{"location":"v2/frontend/pages/admin/email-queue-page/#statistics-card-grid","title":"Statistics Card Grid","text":"<pre><code><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</code></pre> <p>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)</p> <p>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</p>"},{"location":"v2/frontend/pages/admin/email-queue-page/#header-actions","title":"Header Actions","text":"<pre><code>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</code></pre> <p>Dynamic Elements: - Status Tag: Color and text change based on <code>stats.paused</code> boolean - Pause/Resume Button: Icon and label toggle based on current state - Loading States: Separate loading states for refresh (<code>loading</code>) and actions (<code>actionLoading</code>)</p>"},{"location":"v2/frontend/pages/admin/email-queue-page/#state-management","title":"State Management","text":""},{"location":"v2/frontend/pages/admin/email-queue-page/#local-state-no-zustand-store","title":"Local State (No Zustand Store)","text":"<pre><code>const [stats, setStats] = useState<QueueStats | null>(null);\nconst [loading, setLoading] = useState(false);\nconst [actionLoading, setActionLoading] = useState(false);\n</code></pre> <p>State Variables: - <code>stats</code> (QueueStats | null): Current queue statistics (waiting, active, completed, failed, paused) - <code>loading</code> (boolean): Refresh button loading state - <code>actionLoading</code> (boolean): Pause/Resume/Clean buttons loading state (shared)</p> <p>No Global State:</p> <p>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</p>"},{"location":"v2/frontend/pages/admin/email-queue-page/#auto-refresh-with-useeffect","title":"Auto-Refresh with useEffect","text":"<pre><code>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</code></pre> <p>Auto-Refresh Strategy:</p> <ul> <li>Initial load: Immediate fetch on mount</li> <li>Interval: 10 seconds (10,000 milliseconds)</li> <li>Silent refresh: Loading state updates, but no UI disruption</li> <li>Cleanup: Clear interval on unmount to prevent memory leak</li> </ul> <p>Why 10 Seconds?</p> <ul> <li>Faster than dashboard: Email queue needs more frequent updates than canvass dashboard (30s)</li> <li>Balance: Fast enough to catch stuck jobs quickly, slow enough to avoid API overload</li> <li>Email context: Emails send in 1-5 seconds each, so 10s interval catches status changes promptly</li> </ul>"},{"location":"v2/frontend/pages/admin/email-queue-page/#usecallback-optimization","title":"useCallback Optimization","text":"<pre><code>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</code></pre> <p>Why useCallback?</p> <ul> <li>Prevents infinite re-renders: Without useCallback, useEffect would create new function reference on every render</li> <li>useMemo dependency: Header actions use <code>fetchStats</code> in dependency array, so must be stable</li> <li>No unnecessary re-renders: Functions only re-created when dependencies change</li> </ul>"},{"location":"v2/frontend/pages/admin/email-queue-page/#api-integration","title":"API Integration","text":""},{"location":"v2/frontend/pages/admin/email-queue-page/#endpoints-used","title":"Endpoints Used","text":"Method Endpoint Purpose Auth GET <code>/api/email-queue/stats</code> Get queue statistics Required POST <code>/api/email-queue/pause</code> Pause queue processing Required POST <code>/api/email-queue/resume</code> Resume queue processing Required POST <code>/api/email-queue/clean</code> Clean completed jobs Required"},{"location":"v2/frontend/pages/admin/email-queue-page/#load-queue-statistics","title":"Load Queue Statistics","text":"<p>Request:</p> <pre><code>const { data } = await api.get<QueueStats>('/email-queue/stats');\n</code></pre> <p>Response (200 OK):</p> <pre><code>{\n \"waiting\": 23,\n \"active\": 2,\n \"completed\": 1487,\n \"failed\": 12,\n \"paused\": false\n}\n</code></pre> <p>Response Fields: - <code>waiting</code> (number): Jobs queued but not yet picked up by worker - <code>active</code> (number): Jobs currently being processed by worker - <code>completed</code> (number): Successfully completed jobs (still in Redis) - <code>failed</code> (number): Jobs that failed after all retry attempts - <code>paused</code> (boolean): Whether queue is paused (true) or running (false)</p> <p>Backend Calculation:</p> <pre><code>// 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</code></pre> <p>Job Count Breakdown:</p> <ul> <li>Waiting: <code>await queue.getWaitingCount()</code> \u2014 jobs in \"wait\" state</li> <li>Active: <code>await queue.getActiveCount()</code> \u2014 jobs in \"active\" state</li> <li>Completed: <code>await queue.getCompletedCount()</code> \u2014 jobs in \"completed\" state</li> <li>Failed: <code>await queue.getFailedCount()</code> \u2014 jobs in \"failed\" state</li> </ul>"},{"location":"v2/frontend/pages/admin/email-queue-page/#pause-queue","title":"Pause Queue","text":"<p>Request:</p> <pre><code>await api.post('/email-queue/pause');\n</code></pre> <p>Response (200 OK):</p> <pre><code>{\n \"message\": \"Queue paused\"\n}\n</code></pre> <p>Backend Implementation:</p> <pre><code>await queue.pause();\nreturn { message: 'Queue paused' };\n</code></pre> <p>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)</p>"},{"location":"v2/frontend/pages/admin/email-queue-page/#resume-queue","title":"Resume Queue","text":"<p>Request:</p> <pre><code>await api.post('/email-queue/resume');\n</code></pre> <p>Response (200 OK):</p> <pre><code>{\n \"message\": \"Queue resumed\"\n}\n</code></pre> <p>Backend Implementation:</p> <pre><code>await queue.resume();\nreturn { message: 'Queue resumed' };\n</code></pre> <p>Effect: - Queue starts picking up jobs from \"waiting\" state - Workers process jobs according to concurrency setting (default: 1 job at a time)</p>"},{"location":"v2/frontend/pages/admin/email-queue-page/#clean-completed-jobs","title":"Clean Completed Jobs","text":"<p>Request:</p> <pre><code>const { data } = await api.post<{ cleaned: number }>('/email-queue/clean');\n</code></pre> <p>Response (200 OK):</p> <pre><code>{\n \"cleaned\": 1487,\n \"message\": \"Cleaned 1487 completed jobs\"\n}\n</code></pre> <p>Response Fields: - <code>cleaned</code> (number): Number of jobs removed from Redis - <code>message</code> (string): Confirmation message</p> <p>Backend Implementation:</p> <pre><code>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</code></pre> <p>Important: This only removes jobs in \"completed\" state. Failed jobs are preserved for troubleshooting.</p>"},{"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":"<pre><code>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</code></pre> <p>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</p>"},{"location":"v2/frontend/pages/admin/email-queue-page/#clean-old-jobs-flow","title":"Clean Old Jobs Flow","text":"<pre><code>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</code></pre> <p>Key Steps: 1. Set loading state before API call 2. Extract <code>cleaned</code> 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</p>"},{"location":"v2/frontend/pages/admin/email-queue-page/#auto-refresh-setup","title":"Auto-Refresh Setup","text":"<pre><code>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</code></pre> <p>Cleanup Importance:</p> <p>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</p>"},{"location":"v2/frontend/pages/admin/email-queue-page/#header-actions-with-usememo","title":"Header Actions with useMemo","text":"<pre><code>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</code></pre> <p>Why useMemo?</p> <ul> <li>Prevents infinite loop: Header actions passed to <code>setPageHeader</code>, which triggers useEffect if reference changes</li> <li>Optimized re-renders: Only re-creates actions when dependencies change (stats, loading states, handler functions)</li> <li>Stable reference: Ensures AppLayout doesn't unnecessarily re-render header</li> </ul>"},{"location":"v2/frontend/pages/admin/email-queue-page/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/frontend/pages/admin/email-queue-page/#10-second-auto-refresh","title":"10-Second Auto-Refresh","text":"<p>Queue statistics update every 10 seconds:</p> <pre><code>const interval = setInterval(fetchStats, 10_000);\n</code></pre> <p>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)</p> <p>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</p>"},{"location":"v2/frontend/pages/admin/email-queue-page/#shared-action-loading-state","title":"Shared Action Loading State","text":"<p>All action buttons share single <code>actionLoading</code> state:</p> <pre><code>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</code></pre> <p>Why Shared State?</p> <ul> <li>Simplicity: Fewer state variables to manage</li> <li>Prevent concurrent actions: User cannot pause and clean simultaneously</li> <li>UI clarity: All action buttons disabled during any action</li> </ul> <p>Trade-off:</p> <p>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</p>"},{"location":"v2/frontend/pages/admin/email-queue-page/#silent-refresh","title":"Silent Refresh","text":"<p>Auto-refresh doesn't show loading spinner:</p> <pre><code>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</code></pre> <p>Why Silent?</p> <ul> <li>No UI flicker: Statistics update smoothly without visual distraction</li> <li>Better UX: User can read numbers without interruption</li> <li>Manual refresh shows loading: Clicking \"Refresh\" button shows loading spinner on button</li> </ul>"},{"location":"v2/frontend/pages/admin/email-queue-page/#responsive-design","title":"Responsive Design","text":""},{"location":"v2/frontend/pages/admin/email-queue-page/#mobile-layout","title":"Mobile Layout","text":"<p>Statistics cards adapt to mobile viewports:</p> <pre><code><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</code></pre> <p>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)</p>"},{"location":"v2/frontend/pages/admin/email-queue-page/#header-actions_1","title":"Header Actions","text":"<p>Header actions are part of AppLayout's page header:</p> <pre><code>useEffect(() => {\n setPageHeader({ title: 'Email Queue', actions: headerActions });\n return () => setPageHeader(null);\n}, [setPageHeader, headerActions]);\n</code></pre> <p>Mobile Behavior:</p> <p>AppLayout automatically collapses header actions into hamburger menu on mobile: - Desktop: Actions visible in header - Mobile: Actions in dropdown menu (hamburger icon)</p>"},{"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":"<p>All interactive elements are keyboard-accessible:</p> <p>Buttons: - Tab: Focus on next button (Refresh \u2192 Pause \u2192 Clean) - Enter/Space: Activate focused button - Escape: Blur focused button</p> <p>Auto-refresh: - No keyboard interaction needed (automatic updates)</p>"},{"location":"v2/frontend/pages/admin/email-queue-page/#screen-reader-support","title":"Screen Reader Support","text":"<p>All elements have proper ARIA labels:</p> <p>Statistics Cards: <pre><code><Statistic\n title=\"Waiting\"\n value={stats?.waiting ?? 0}\n aria-label={`${stats?.waiting ?? 0} waiting jobs`}\n/>\n</code></pre></p> <p>Status Tag: <pre><code><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</code></pre></p> <p>Action Buttons: <pre><code><Button\n icon={<ReloadOutlined />}\n onClick={fetchStats}\n aria-label=\"Refresh queue statistics\"\n>\n Refresh\n</Button>\n</code></pre></p>"},{"location":"v2/frontend/pages/admin/email-queue-page/#color-contrast","title":"Color Contrast","text":"<p>All color-coded elements meet WCAG AA standards:</p> <p>Statistic Values: - Waiting (blue): <code>#1890ff</code> on white = 4.5:1 contrast (AA) - Active (green): <code>#52c41a</code> on white = 3.0:1 contrast (AA for large text) - Completed (gray): <code>rgba(0,0,0,0.85)</code> on white = 13.6:1 contrast (AAA) - Failed (red): <code>#ff4d4f</code> on white = 4.5:1 contrast (AA)</p> <p>Status Tags: - RUNNING (green): <code>#52c41a</code> background with white text = 3.5:1 contrast (AA for large text) - PAUSED (orange): <code>#fa8c16</code> background with white text = 3.2:1 contrast (AA for large text)</p>"},{"location":"v2/frontend/pages/admin/email-queue-page/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/frontend/pages/admin/email-queue-page/#statistics-not-updating","title":"Statistics Not Updating","text":"<p>Problem: Navigate to Email Queue page, statistics load initially, but don't update after 10 seconds.</p> <p>Diagnosis:</p> <p>Check browser console for errors:</p> <pre><code>// 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</code></pre> <p>Possible Causes:</p> <ol> <li>JWT token expired:</li> <li>Access token expired, refresh token not working</li> <li> <p>User needs to log out and log back in</p> </li> <li> <p>Interval cleared prematurely:</p> </li> <li>Component unmounted and remounted (React Strict Mode in development)</li> <li> <p>useEffect cleanup called too early</p> </li> <li> <p>Backend API down:</p> </li> <li>API container not running</li> <li>Email queue service crashed</li> </ol> <p>Solution:</p> <ol> <li>For token issues:</li> <li>Refresh page to trigger token refresh</li> <li>If that fails, log out and log back in</li> <li> <p>Check JWT_ACCESS_SECRET and JWT_REFRESH_SECRET env vars</p> </li> <li> <p>For interval issues:</p> </li> <li>Accept that development mode unmounts/remounts components</li> <li> <p>Ensure production build works correctly (no double mounting)</p> </li> <li> <p>For backend issues:</p> </li> <li>Check API container: <code>docker compose ps api</code></li> <li>Check API logs: <code>docker compose logs -f api | grep email-queue</code></li> <li>Restart API: <code>docker compose restart api</code></li> </ol>"},{"location":"v2/frontend/pages/admin/email-queue-page/#pause-button-not-working","title":"Pause Button Not Working","text":"<p>Problem: Click \"Pause\" button, success message appears, but queue status remains \"RUNNING\" (green).</p> <p>Diagnosis:</p> <p>Check API logs:</p> <pre><code>docker compose logs -f api | grep \"Queue pause\"\n</code></pre> <p>Expected output:</p> <pre><code>API: Queue paused successfully\n</code></pre> <p>Actual output:</p> <pre><code>API: Error pausing queue: Queue not initialized\n</code></pre> <p>Possible Causes:</p> <ol> <li>BullMQ queue not initialized:</li> <li>Email queue service failed to start</li> <li> <p>Redis connection error during queue initialization</p> </li> <li> <p>Redis connection lost:</p> </li> <li>Redis container down</li> <li> <p>Network connectivity issue between API and Redis</p> </li> <li> <p>Multiple workers:</p> </li> <li>Multiple API containers running, only one paused</li> <li>Other workers continue processing jobs</li> </ol> <p>Solution:</p> <ol> <li>For queue initialization:</li> <li>Check email queue service: <code>docker compose logs api | grep \"Email queue service\"</code></li> <li>Expected: \"Email queue service started successfully\"</li> <li> <p>If missing, check Redis connection</p> </li> <li> <p>For Redis issues:</p> </li> <li>Check Redis container: <code>docker compose ps redis</code></li> <li>Test Redis connection: <code>docker compose exec redis redis-cli PING</code></li> <li>Expected: \"PONG\"</li> <li> <p>If down, restart: <code>docker compose restart redis</code></p> </li> <li> <p>For multiple workers:</p> </li> <li>Check running API containers: <code>docker compose ps api</code></li> <li>Scale down to single instance: <code>docker compose up -d --scale api=1</code></li> </ol>"},{"location":"v2/frontend/pages/admin/email-queue-page/#failed-count-increasing","title":"\"Failed\" Count Increasing","text":"<p>Problem: \"Failed\" count increases from 5 to 50 over time.</p> <p>Diagnosis:</p> <p>Check failed jobs in Redis:</p> <pre><code>docker compose exec redis redis-cli\n> LRANGE bull:email-queue:failed 0 -1\n</code></pre> <p>Check API logs for failure reasons:</p> <pre><code>docker compose logs -f api | grep \"Email job failed\"\n</code></pre> <p>Common error messages:</p> <pre><code>Email job failed: SMTP connection timeout\nEmail job failed: Authentication failed (535)\nEmail job failed: Recipient address rejected\n</code></pre> <p>Possible Causes:</p> <ol> <li>SMTP server issues:</li> <li>SMTP server down (connection timeout)</li> <li>Invalid credentials (authentication failed)</li> <li> <p>Rate limiting (too many emails sent)</p> </li> <li> <p>Invalid recipient addresses:</p> </li> <li>Email addresses with typos</li> <li>Non-existent domains</li> <li> <p>Blocked by recipient server</p> </li> <li> <p>Network connectivity:</p> </li> <li>Firewall blocking SMTP ports (25, 587, 465)</li> <li>DNS resolution failure</li> </ol> <p>Solution:</p> <ol> <li>For SMTP server issues:</li> <li>Test SMTP connection: Navigate to <code>/app/settings</code>, click \"Test Connection\"</li> <li>Update SMTP credentials if authentication failed</li> <li> <p>Wait 5 minutes if rate limited, then resume queue</p> </li> <li> <p>For invalid addresses:</p> </li> <li>Review campaign email list: Navigate to <code>/app/influence/campaigns</code></li> <li>Check representative email addresses: Navigate to <code>/app/influence/representatives</code></li> <li> <p>Delete invalid addresses or update to correct ones</p> </li> <li> <p>For network issues:</p> </li> <li>Check firewall rules: <code>sudo iptables -L | grep 587</code></li> <li>Test DNS: <code>nslookup smtp.protonmail.ch</code></li> <li>Test SMTP port: <code>telnet smtp.protonmail.ch 587</code></li> </ol>"},{"location":"v2/frontend/pages/admin/email-queue-page/#clean-button-removes-all-jobs","title":"Clean Button Removes All Jobs","text":"<p>Problem: Click \"Clean Old Jobs\" expecting to remove only completed jobs, but all jobs disappear (waiting + completed).</p> <p>Diagnosis:</p> <p>Check API logs:</p> <pre><code>docker compose logs api | grep \"clean\"\n</code></pre> <p>Expected:</p> <pre><code>Cleaned 1487 completed jobs\n</code></pre> <p>Actual:</p> <pre><code>Cleaned 1500 jobs (completed + waiting + failed)\n</code></pre> <p>Possible Causes:</p> <ol> <li>Backend bug:</li> <li>Clean endpoint removing all job types, not just completed</li> <li> <p>BullMQ clean() called with wrong parameters</p> </li> <li> <p>User misunderstanding:</p> </li> <li>Clean button label unclear (should say \"Clean Completed Jobs\")</li> <li>User expected to remove failed jobs too</li> </ol> <p>Solution:</p> <ol> <li>For backend bug (developer fix):</li> <li>Update clean endpoint to only remove completed jobs: <pre><code>await queue.clean(0, 'completed'); // Only completed, not 'failed' or 'waiting'\n</code></pre></li> <li> <p>Test: Add jobs to queue, click clean, verify waiting/failed jobs remain</p> </li> <li> <p>For unclear UI:</p> </li> <li>Update button label: \"Clean Old Jobs\" \u2192 \"Clean Completed Jobs\"</li> <li>Add tooltip: \"Removes completed jobs from queue. Failed and waiting jobs are preserved.\"</li> </ol>"},{"location":"v2/frontend/pages/admin/email-queue-page/#statistics-show-wrong-counts","title":"Statistics Show Wrong Counts","text":"<p>Problem: \"Completed\" count shows 1,487, but only 500 emails were sent.</p> <p>Diagnosis:</p> <p>Check actual job counts in Redis:</p> <pre><code>docker compose exec redis redis-cli\n> LLEN bull:email-queue:completed\n</code></pre> <p>Expected: 1487 (matches UI)</p> <p>Check campaign email records in database:</p> <pre><code>SELECT COUNT(*) FROM \"CampaignEmail\" WHERE status = 'SENT';\n</code></pre> <p>Result: 500 (mismatch with Redis count)</p> <p>Possible Causes:</p> <ol> <li>Duplicate jobs:</li> <li>Multiple jobs created for same email</li> <li> <p>Job retry logic creating duplicates</p> </li> <li> <p>Test jobs:</p> </li> <li>Developer testing created many jobs</li> <li> <p>Test mode emails counting toward total</p> </li> <li> <p>Redis not cleaned:</p> </li> <li>Completed jobs from previous campaigns still in Redis</li> <li>Clean operation not run in months</li> </ol> <p>Solution:</p> <ol> <li>For duplicates:</li> <li>Investigate job creation logic in CampaignsPage</li> <li>Ensure single job created per campaign email</li> <li> <p>Add deduplication: Check if job already exists before creating</p> </li> <li> <p>For test jobs:</p> </li> <li>Clean queue after testing: Click \"Clean Old Jobs\"</li> <li> <p>Use separate test queue (not production queue)</p> </li> <li> <p>For stale jobs:</p> </li> <li>Run clean operation regularly (weekly)</li> <li>Consider auto-clean after 30 days (backend cron job)</li> </ol>"},{"location":"v2/frontend/pages/admin/email-queue-page/#related-documentation","title":"Related Documentation","text":"<ul> <li>Email Queue Backend Module \u2014 Backend email queue service</li> <li>Email Queue API Reference \u2014 Queue API endpoints</li> <li>Email Service \u2014 Email sending service (Nodemailer)</li> <li>Email Queue Service \u2014 BullMQ queue + worker</li> <li>CampaignsPage \u2014 Campaign management (triggers email jobs)</li> <li>Campaign Emails Drawer \u2014 Email stats drawer (links to queue page)</li> <li>SettingsPage \u2014 SMTP configuration</li> <li>BullMQ Documentation \u2014 Official BullMQ docs</li> <li>Troubleshooting: Email Issues \u2014 Email troubleshooting guide</li> </ul>"},{"location":"v2/frontend/pages/admin/email-template-editor-page/","title":"EmailTemplateEditorPage","text":""},{"location":"v2/frontend/pages/admin/email-template-editor-page/#overview","title":"Overview","text":"<p>File: <code>admin/src/pages/EmailTemplateEditorPage.tsx</code> Route: <code>/app/email-templates/:id/edit</code> Role Requirements: <code>SUPER_ADMIN</code>, <code>INFLUENCE_ADMIN</code>, <code>MAP_ADMIN</code></p> <p>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.</p> <p>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)</p> <p>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)</p>"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#screenshot","title":"Screenshot","text":"<p>[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.]</p>"},{"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":"<ol> <li>Dual Editor Layout</li> <li>Left pane (40% width): HTML content editor with syntax highlighting</li> <li>Center pane (40% width): Plain text content editor</li> <li>Right pane (20% width): Variables reference + previews</li> <li>VS Dark theme for all Monaco editors</li> <li> <p>Line numbers, word wrap, no minimap for clean editing</p> </li> <li> <p>Subject Line Editor</p> </li> <li>Input field with envelope icon</li> <li>Supports variable interpolation (e.g., <code>{{CAMPAIGN_NAME}}</code>)</li> <li>Large size input for visibility</li> <li> <p>Saved together with HTML/text content</p> </li> <li> <p>Variables Reference Panel</p> </li> <li>Table showing all template variables with columns:<ul> <li>Variable: Code format (e.g., <code>{{FIRST_NAME}}</code>)</li> <li>Label: Human-readable name</li> <li>Description: Usage explanation</li> <li>Required: Red \"Required\" or gray \"Optional\" tag</li> </ul> </li> <li>Sample data input fields for preview</li> <li> <p>Persists sample values during editing session</p> </li> <li> <p>Real-Time Previews</p> </li> <li>HTML Preview Tab: Sandboxed iframe rendering processed HTML</li> <li>Text Preview Tab: Pre-formatted text block with styling</li> <li>Live updates when sample data changes</li> <li> <p>Variable interpolation uses simple string replacement</p> </li> <li> <p>Save Operations</p> </li> <li>Save button in toolbar (primary, blue)</li> <li>Ctrl+S (or Cmd+S on Mac) keyboard shortcut</li> <li>Creates new version in database</li> <li>Success message on save</li> <li> <p>Updates template timestamp</p> </li> <li> <p>Test Email Functionality</p> </li> <li>Test Email button opens TestEmailModal</li> <li>Fill in variable values and recipient</li> <li>Sends email with current editor content (not saved)</li> <li> <p>Success message on send</p> </li> <li> <p>Template Metadata Display</p> </li> <li>Template name in toolbar</li> <li>Category tag (color-coded: blue=Influence, green=Map, purple=System)</li> <li>System template indicator (blue SYSTEM tag)</li> <li> <p>Back button to return to templates list</p> </li> <li> <p>Mobile Detection</p> </li> <li>Detects screens < 768px (md breakpoint)</li> <li>Shows warning Result component</li> <li>\"Desktop Required\" message</li> <li> <p>Back button to return to templates list</p> </li> <li> <p>Dark Theme Editor</p> </li> <li>VS Dark Monaco theme</li> <li>Consistent with code editor expectations</li> <li>High contrast for readability</li> <li>Token colors from Ant Design theme</li> </ol>"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#user-workflow","title":"User Workflow","text":""},{"location":"v2/frontend/pages/admin/email-template-editor-page/#opening-editor","title":"Opening Editor","text":"<ol> <li>Navigate from templates list: Click Edit button on EmailTemplatesPage</li> <li>Route loads: <code>/app/email-templates/:id/edit</code></li> <li>Template fetches: Loading spinner while fetching template data</li> <li>Editor displays: Full-screen layout with template content</li> </ol>"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#editing-template","title":"Editing Template","text":"<ol> <li>Modify subject line: Type in top input field, use <code>{{VARIABLES}}</code> as needed</li> <li>Edit HTML content: Click in left Monaco editor, write HTML markup</li> <li>Edit text content: Click in center Monaco editor, write plain text</li> <li>Check syntax: Monaco provides HTML syntax highlighting and error detection</li> <li>Save changes: Click Save button or press Ctrl+S</li> </ol>"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#using-variables","title":"Using Variables","text":"<ol> <li>View variables table: Click Variables tab in right sidebar</li> <li>Check variable syntax: Copy <code>{{VARIABLE_NAME}}</code> from table</li> <li>Insert in content: Paste into subject line, HTML, or text editor</li> <li>Mark required variables: Red \"Required\" tag indicates mandatory variables</li> <li>Reference descriptions: Read description column for usage guidance</li> </ol>"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#previewing-changes","title":"Previewing Changes","text":"<ol> <li>Enter sample data: In Variables tab, fill in input fields below table</li> <li>Switch to preview: Click \"HTML Preview\" or \"Text Preview\" tab</li> <li>View rendered output: Iframe shows HTML with variables replaced</li> <li>Update sample data: Change input values to see different renderings</li> <li>Verify output: Check that variables interpolate correctly</li> </ol>"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#testing-email","title":"Testing Email","text":"<ol> <li>Click Test Email button: Opens TestEmailModal</li> <li>Fill in variables: Enter values for each template variable</li> <li>Enter recipient email: Provide test email address</li> <li>Send test: Click Send button</li> <li>Check inbox: Verify email received (or MailHog in dev mode)</li> <li>Review formatting: Check HTML rendering in email client</li> </ol>"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#saving-template","title":"Saving Template","text":"<ol> <li>Make changes: Edit subject, HTML, or text content</li> <li>Save with Ctrl+S: Keyboard shortcut (or click Save button)</li> <li>Loading state: Save button shows spinner</li> <li>Success message: \"Template saved successfully\" notification</li> <li>New version created: Template version history incremented</li> <li>Continue editing: Can continue making changes and saving again</li> </ol>"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#returning-to-list","title":"Returning to List","text":"<ol> <li>Click back button: Arrow icon in top-left of toolbar</li> <li>Navigate back: Browser back button also works</li> <li>Unsaved changes: No confirmation prompt (consider implementing)</li> <li>Route change: Returns to <code>/app/email-templates</code></li> </ol>"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#component-breakdown","title":"Component Breakdown","text":""},{"location":"v2/frontend/pages/admin/email-template-editor-page/#top-toolbar","title":"Top Toolbar","text":"<pre><code><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</code></pre> <p>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</p>"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#subject-line-input","title":"Subject Line Input","text":"<pre><code><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</code></pre> <p>Props: - <code>value</code>: Controlled input with <code>subjectLine</code> state - <code>placeholder</code>: Explains variable syntax - <code>prefix</code>: Envelope icon for visual context - <code>size=\"large\"</code>: 40px height for prominence</p>"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#html-editor-monaco","title":"HTML Editor (Monaco)","text":"<pre><code><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</code></pre> <p>Options: - <code>minimap: false</code> - No code minimap (saves space) - <code>fontSize: 14</code> - Readable code size - <code>wordWrap: 'on'</code> - Wrap long lines instead of horizontal scroll - <code>lineNumbers: 'on'</code> - Show line numbers for reference - <code>scrollBeyondLastLine: false</code> - Don't scroll past last line</p>"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#text-editor-monaco","title":"Text Editor (Monaco)","text":"<pre><code><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</code></pre> <p>Same options as HTML editor but language is <code>plaintext</code> (no syntax highlighting).</p>"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#variables-table","title":"Variables Table","text":"<pre><code><Table\n dataSource={template.variables}\n columns={variableColumns}\n rowKey=\"id\"\n size=\"small\"\n pagination={false}\n/>\n</code></pre> <p>Columns: <pre><code>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</code></pre></p>"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#sample-data-inputs","title":"Sample Data Inputs","text":"<pre><code><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</code></pre> <p>Pattern: One input per variable, labeled with variable label, placeholder shows default sample value.</p>"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#html-preview-iframe","title":"HTML Preview Iframe","text":"<pre><code><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</code></pre> <p>Security: <code>sandbox=\"allow-same-origin\"</code> restricts iframe capabilities (no scripts, no forms).</p> <p><code>srcDoc</code> prop: Renders inline HTML without external URL.</p>"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#text-preview-block","title":"Text Preview Block","text":"<pre><code><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</code></pre> <p>Styling: - <code>whiteSpace: 'pre-wrap'</code> - Preserve whitespace but wrap long lines - <code>fontFamily: 'monospace'</code> - Fixed-width font like email clients use - Background color for contrast</p>"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#template-processing-function","title":"Template Processing Function","text":"<pre><code>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</code></pre> <p>Usage: <pre><code>const processedHtml = processTemplate(htmlContent, sampleData);\nconst processedText = processTemplate(textContent, sampleData);\n</code></pre></p>"},{"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":"<p>Template Data: <pre><code>const [template, setTemplate] = useState<EmailTemplate | null>(null);\nconst [loading, setLoading] = useState(true);\n</code></pre></p> <p>Editor Content: <pre><code>const [subjectLine, setSubjectLine] = useState('');\nconst [htmlContent, setHtmlContent] = useState('');\nconst [textContent, setTextContent] = useState('');\n</code></pre></p> <p>Sample Data for Preview: <pre><code>const [sampleData, setSampleData] = useState<Record<string, string>>({});\n</code></pre></p> <p>UI State: <pre><code>const [saving, setSaving] = useState(false);\nconst [activeTab, setActiveTab] = useState('variables');\nconst [testModalOpen, setTestModalOpen] = useState(false);\n</code></pre></p> <p>Responsive State: <pre><code>const screens = Grid.useBreakpoint();\nconst isMobile = !screens.md;\n</code></pre></p>"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#data-fetching","title":"Data Fetching","text":"<p>Fetch Template on Mount: <pre><code>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</code></pre></p> <p>Error Handling: Redirect to templates list if template not found.</p>"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#save-handler","title":"Save Handler","text":"<pre><code>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</code></pre>"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#keyboard-shortcut","title":"Keyboard Shortcut","text":"<pre><code>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</code></pre> <p>Why <code>e.preventDefault()</code>? Prevents browser's default \"Save Page\" dialog.</p>"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#api-integration","title":"API Integration","text":""},{"location":"v2/frontend/pages/admin/email-template-editor-page/#endpoints-used","title":"Endpoints Used","text":"<p>GET <code>/email-templates/:id</code> - Fetch template <pre><code>const { data } = await api.get<EmailTemplate>(`/email-templates/${id}`);\n</code></pre></p> <p>Response: <pre><code>{\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</code></pre></p> <p>PUT <code>/email-templates/:id</code> - Update template <pre><code>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</code></pre></p> <p>Response: Returns updated EmailTemplate object with new <code>updatedAt</code> timestamp.</p>"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#code-examples","title":"Code Examples","text":""},{"location":"v2/frontend/pages/admin/email-template-editor-page/#keyboard-shortcut-pattern","title":"Keyboard Shortcut Pattern","text":"<pre><code>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</code></pre> <p>Pattern: 1. Use <code>useCallback</code> for save handler with dependencies 2. Add keyboard event listener in <code>useEffect</code> 3. Check Ctrl/Cmd + S key combination 4. Call preventDefault to stop browser save dialog 5. Clean up listener on unmount</p>"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#variable-interpolation","title":"Variable Interpolation","text":"<pre><code>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</code></pre> <p>Note: This is a simple string replacement for preview. Production email sending uses server-side template engine with proper escaping.</p>"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#mobile-detection","title":"Mobile Detection","text":"<pre><code>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</code></pre> <p>Breakpoint: <code>md</code> = 768px (Ant Design standard)</p>"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#sample-data-initialization","title":"Sample Data Initialization","text":"<pre><code>// 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</code></pre> <p>Pattern: Pre-fill sample data inputs with default sample values from variable definitions.</p>"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#category-color-helper","title":"Category Color Helper","text":"<pre><code>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</code></pre> <p>Consistent with EmailTemplatesPage color scheme.</p>"},{"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":"<p>Monaco Editor is loaded via CDN when component mounts: <pre><code>import Editor from '@monaco-editor/react';\n</code></pre></p> <p>Bundle size: Monaco not included in main bundle (reduces initial load).</p>"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#usecallback-for-save-handler","title":"useCallback for Save Handler","text":"<pre><code>const handleSave = useCallback(async () => { /* ... */ }, [template, id, subjectLine, htmlContent, textContent]);\n</code></pre> <p>Why: Prevents recreation on every render, essential for keyboard shortcut listener dependency.</p>"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#controlled-inputs","title":"Controlled Inputs","text":"<p>All three editors (subject, HTML, text) use controlled state: <pre><code><Input value={subjectLine} onChange={(e) => setSubjectLine(e.target.value)} />\n<Editor value={htmlContent} onChange={(value) => setHtmlContent(value || '')} />\n</code></pre></p> <p>Tradeoff: Controlled inputs = React re-renders on every keystroke, but ensures state consistency.</p>"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#iframe-preview-updates","title":"Iframe Preview Updates","text":"<p>Preview iframe updates only when: 1. Sample data changes 2. Editor content changes (via <code>processedHtml</code> dependency)</p> <p>No automatic refresh timer needed.</p>"},{"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":"<pre><code>const isMobile = !screens.md;\n\nif (isMobile) {\n return <Result status=\"warning\" title=\"Desktop Required\" />;\n}\n</code></pre> <p>Screens < 768px: Show warning, don't render editor (unusable on small screens).</p>"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#full-screen-layout","title":"Full-Screen Layout","text":"<pre><code><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</code></pre> <p><code>height: 100vh</code> ensures full viewport height, no scrolling.</p>"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#flex-layout","title":"Flex Layout","text":"<pre><code><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</code></pre> <p>Flex basis percentages: Fixed width columns, no shrinking/growing.</p>"},{"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":"<ul> <li>Tab: Navigate between subject input, editors, buttons</li> <li>Ctrl+S / Cmd+S: Save template</li> <li>Monaco shortcuts: Ctrl+F (find), Ctrl+H (replace), Ctrl+/ (comment)</li> </ul>"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#button-labels","title":"Button Labels","text":"<pre><code><Button icon={<SaveOutlined />}>Save</Button>\n<Button icon={<SendOutlined />}>Test Email</Button>\n</code></pre> <p>Not icon-only buttons \u2013 text labels for clarity.</p>"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#input-placeholders","title":"Input Placeholders","text":"<pre><code><Input placeholder=\"Email Subject Line (use {{VARIABLES}})\" />\n</code></pre> <p>Descriptive placeholder explains variable syntax.</p>"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#preview-iframe-sandbox","title":"Preview Iframe Sandbox","text":"<pre><code><iframe sandbox=\"allow-same-origin\" />\n</code></pre> <p>Security: Restricts iframe capabilities (no JavaScript execution from injected HTML).</p>"},{"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":"<p>Symptoms: - Loading spinner forever - Error message \"Failed to load template\" - Redirect to templates list</p> <p>Causes: 1. Invalid template ID in URL 2. API server down 3. Template deleted 4. Permission denied</p> <p>Solutions: <pre><code># 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</code></pre></p>"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#save-not-working","title":"Save Not Working","text":"<p>Symptoms: - Clicking Save does nothing - Ctrl+S has no effect - No success/error message</p> <p>Causes: 1. <code>handleSave</code> callback not defined 2. Keyboard listener not registered 3. Network error</p> <p>Debug: <pre><code>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</code></pre></p>"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#preview-not-updating","title":"Preview Not Updating","text":"<p>Symptoms: - Changing sample data doesn't update preview - Preview shows old content</p> <p>Causes: 1. <code>processTemplate</code> function not called 2. Sample data state not updating 3. Iframe not re-rendering</p> <p>Debug: <pre><code>const processedHtml = processTemplate(htmlContent, sampleData);\nconsole.log('Sample data:', sampleData);\nconsole.log('Processed HTML:', processedHtml);\n</code></pre></p>"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#variables-not-showing","title":"Variables Not Showing","text":"<p>Symptoms: - Variables table empty - Sample data inputs not rendering</p> <p>Cause: - Template has no variables defined</p> <p>Expected Behavior: - If <code>template.variables</code> is empty array, table shows no rows - This is valid (template may not use variables)</p>"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#mobile-warning-not-showing","title":"Mobile Warning Not Showing","text":"<p>Symptoms: - Editor renders on mobile (broken layout)</p> <p>Cause: - Breakpoint detection not working</p> <p>Debug: <pre><code>const screens = Grid.useBreakpoint();\nconsole.log('Breakpoints:', screens);\nconsole.log('Is mobile:', !screens.md);\n</code></pre></p>"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#monaco-editor-blank","title":"Monaco Editor Blank","text":"<p>Symptoms: - Editor pane shows nothing (white/black) - No code visible</p> <p>Causes: 1. Monaco CDN failed to load 2. Content is empty string 3. Height not set correctly</p> <p>Solutions: <pre><code>// 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</code></pre></p>"},{"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":"<ul> <li>Email Templates Module - Service, schemas, routes</li> <li>Email Templates API Reference - Full endpoint documentation</li> </ul>"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#frontend-pages","title":"Frontend Pages","text":"<ul> <li>Email Templates Page - Template list and management</li> </ul>"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#frontend-components","title":"Frontend Components","text":"<ul> <li>TestEmailModal Component - Test email modal</li> </ul>"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#features_1","title":"Features","text":"<ul> <li>Email Template System - Feature overview</li> <li>Template Variables - Variable system documentation</li> <li>Template Versioning - Version control system</li> </ul>"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#user-guides","title":"User Guides","text":"<ul> <li>Admin Guide - Email Templates - Template editing workflows</li> </ul>"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#external-resources","title":"External Resources","text":"<ul> <li>Monaco Editor Documentation - Monaco API reference</li> <li>Monaco React Documentation - React wrapper docs</li> </ul>"},{"location":"v2/frontend/pages/admin/email-template-editor-page/#related-technologies","title":"Related Technologies","text":"<ul> <li>GrapesJS Editor Page - Similar full-screen editor for landing pages</li> <li>DocsPage - Similar Monaco editor for documentation files</li> </ul>"},{"location":"v2/frontend/pages/admin/email-templates-page/","title":"EmailTemplatesPage","text":""},{"location":"v2/frontend/pages/admin/email-templates-page/#overview","title":"Overview","text":"<p>File: <code>admin/src/pages/EmailTemplatesPage.tsx</code> Route: <code>/app/email-templates</code> Role Requirements: <code>SUPER_ADMIN</code>, <code>INFLUENCE_ADMIN</code>, <code>MAP_ADMIN</code></p> <p>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.</p> <p>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)</p> <p>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)</p>"},{"location":"v2/frontend/pages/admin/email-templates-page/#screenshot","title":"Screenshot","text":"<p>[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.]</p>"},{"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":"<ol> <li>Template List Management</li> <li>Paginated table showing all email templates (default 20 per page)</li> <li>Name column shows template name + key, system templates marked with blue SYSTEM tag</li> <li>Category column color-coded (blue=Influence, green=Map, purple=System)</li> <li>Subject line preview (truncated to 50 chars)</li> <li>Active/Inactive badge status</li> <li>Relative timestamp (e.g., \"2 hours ago\")</li> <li> <p>Four action buttons per row (Edit, Test, Versions, Delete)</p> </li> <li> <p>Search & Filtering</p> </li> <li>Real-time search by name or key (300ms debounce)</li> <li>Category filter dropdown (All, Influence, Map, System)</li> <li>Active status filter (All, Active, Inactive)</li> <li> <p>Filters trigger automatic refetch with page reset to 1</p> </li> <li> <p>Template Actions</p> </li> <li>Edit: Navigate to full-screen Monaco editor (<code>/app/email-templates/:id/edit</code>)</li> <li>Test: Open modal to send test email with sample data</li> <li>Versions: Open drawer showing version history with rollback options</li> <li> <p>Delete: Popconfirm with warning (only for non-system templates)</p> </li> <li> <p>Pagination Controls</p> </li> <li>Page size options: 10, 20, 50, 100</li> <li>Show total count (e.g., \"Total 15 templates\")</li> <li> <p>Current page and page size preserved during search/filter operations</p> </li> <li> <p>System Template Protection</p> </li> <li>System templates (<code>isSystem: true</code>) cannot be deleted</li> <li>Delete button hidden for system templates</li> <li> <p>Blue SYSTEM tag displayed in name column</p> </li> <li> <p>Test Email Modal</p> </li> <li>Fill in variable values with form inputs</li> <li>Send test email to specified recipient</li> <li>Uses template's current HTML + text content</li> <li> <p>Success message on send</p> </li> <li> <p>Version History</p> </li> <li>Drawer showing all historical versions</li> <li>View previous subject lines, HTML, and text content</li> <li>Rollback to any previous version</li> <li>Refetches template list after rollback</li> </ol>"},{"location":"v2/frontend/pages/admin/email-templates-page/#user-workflow","title":"User Workflow","text":""},{"location":"v2/frontend/pages/admin/email-templates-page/#viewing-templates","title":"Viewing Templates","text":"<ol> <li>Navigate to page: Admin sidebar \u2192 Email \u2192 Templates</li> <li>Browse templates: Table shows all templates with pagination</li> <li>View details: Click on template name or use filters to narrow list</li> <li>Check status: Green \"Active\" badge = enabled, gray \"Inactive\" badge = disabled</li> </ol>"},{"location":"v2/frontend/pages/admin/email-templates-page/#searching-templates","title":"Searching Templates","text":"<ol> <li>Enter search query: Type in search bar (name or key)</li> <li>Debounced search: 300ms delay prevents excessive API calls</li> <li>Clear search: Click X icon or clear input</li> <li>Results update: Table refreshes with matching templates, page resets to 1</li> </ol>"},{"location":"v2/frontend/pages/admin/email-templates-page/#filtering-templates","title":"Filtering Templates","text":"<ol> <li>Select category: Choose from All, Influence, Map, System dropdown</li> <li>Select active status: Choose from All, Active, Inactive dropdown</li> <li>Combined filters: Search + category + active status work together</li> <li>Reset filters: Change dropdowns back to \"All\" or clear search</li> </ol>"},{"location":"v2/frontend/pages/admin/email-templates-page/#editing-template","title":"Editing Template","text":"<ol> <li>Click Edit button: Opens full-screen Monaco editor in new route</li> <li>Modify content: Edit subject line, HTML, and text content</li> <li>Save changes: Ctrl+S or click Save button (creates new version)</li> <li>Return to list: Browser back button or navigate away</li> </ol>"},{"location":"v2/frontend/pages/admin/email-templates-page/#testing-email","title":"Testing Email","text":"<ol> <li>Click Test button: Opens TestEmailModal</li> <li>Fill in variables: Enter sample data for template variables</li> <li>Enter recipient: Provide email address for test</li> <li>Send test: Click Send button</li> <li>Check result: Success message or error message</li> <li>Verify email: Check recipient inbox (or MailHog in dev mode)</li> </ol>"},{"location":"v2/frontend/pages/admin/email-templates-page/#viewing-version-history","title":"Viewing Version History","text":"<ol> <li>Click Versions button: Opens VersionHistoryDrawer on right side</li> <li>Browse versions: See all historical versions with timestamps</li> <li>View version details: Expand version to see full content</li> <li>Rollback (if needed): Click Rollback button to restore previous version</li> <li>Close drawer: Click X or click outside drawer</li> </ol>"},{"location":"v2/frontend/pages/admin/email-templates-page/#deleting-template","title":"Deleting Template","text":"<ol> <li>Verify not system template: Check for absence of SYSTEM tag</li> <li>Click Delete button: Opens Popconfirm dialog</li> <li>Read warning: \"This action cannot be undone\"</li> <li>Confirm deletion: Click OK in popconfirm</li> <li>Template removed: Table refreshes, template no longer shown</li> </ol>"},{"location":"v2/frontend/pages/admin/email-templates-page/#component-breakdown","title":"Component Breakdown","text":""},{"location":"v2/frontend/pages/admin/email-templates-page/#table-component","title":"Table Component","text":"<pre><code><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</code></pre> <p>Column Configuration:</p> Column Dataindex Responsive Render Logic Name name Always visible Shows name + key, SYSTEM tag for system templates Category category Hidden on mobile (<code>['md']</code>) Color-coded tag (blue/green/purple) Subject subjectLine Hidden on small tablets (<code>['lg']</code>) 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":"<pre><code><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</code></pre> <p>Debounce Logic: <pre><code>const handleSearchChange = (value: string) => {\n setSearch(value); // Update input immediately\n clearTimeout(searchTimerRef.current);\n searchTimerRef.current = setTimeout(() => setDebouncedSearch(value), 300);\n};\n</code></pre></p>"},{"location":"v2/frontend/pages/admin/email-templates-page/#category-filter","title":"Category Filter","text":"<pre><code><Select\n value={categoryFilter}\n onChange={setCategoryFilter}\n options={categoryOptions}\n style={{ width: 180 }}\n/>\n</code></pre> <p>Options: <pre><code>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</code></pre></p>"},{"location":"v2/frontend/pages/admin/email-templates-page/#active-status-filter","title":"Active Status Filter","text":"<pre><code><Select\n value={activeFilter}\n onChange={setActiveFilter}\n options={activeOptions}\n style={{ width: 150 }}\n/>\n</code></pre> <p>Options: <pre><code>const activeOptions = [\n { value: 'ALL', label: 'All Status' },\n { value: 'ACTIVE', label: 'Active' },\n { value: 'INACTIVE', label: 'Inactive' },\n];\n</code></pre></p>"},{"location":"v2/frontend/pages/admin/email-templates-page/#action-buttons","title":"Action Buttons","text":"<pre><code><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</code></pre>"},{"location":"v2/frontend/pages/admin/email-templates-page/#testemailmodal","title":"TestEmailModal","text":"<p>Props: - <code>open: boolean</code> - Modal visibility - <code>template: EmailTemplate</code> - Template to test - <code>onClose: () => void</code> - Close callback - <code>onSuccess: () => void</code> - Success callback</p> <p>Usage: <pre><code><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</code></pre></p>"},{"location":"v2/frontend/pages/admin/email-templates-page/#versionhistorydrawer","title":"VersionHistoryDrawer","text":"<p>Props: - <code>open: boolean</code> - Drawer visibility - <code>templateId: string</code> - Template ID - <code>templateName: string</code> - Template name for header - <code>onClose: () => void</code> - Close callback - <code>onRollbackSuccess: () => void</code> - Rollback success callback</p> <p>Usage: <pre><code><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</code></pre></p>"},{"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":"<p>Template List State: <pre><code>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</code></pre></p> <p>Search & Filter State: <pre><code>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</code></pre></p> <p>Modal State: <pre><code>const [testModalOpen, setTestModalOpen] = useState(false);\nconst [selectedTemplate, setSelectedTemplate] = useState<EmailTemplate | null>(null);\nconst [versionDrawerOpen, setVersionDrawerOpen] = useState(false);\n</code></pre></p>"},{"location":"v2/frontend/pages/admin/email-templates-page/#data-fetching","title":"Data Fetching","text":"<p>Fetch Templates Function: <pre><code>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</code></pre></p> <p>Auto-Refetch on Filter Changes: <pre><code>useEffect(() => {\n fetchTemplates({ page: 1 });\n}, [debouncedSearch, categoryFilter, activeFilter]); // Reset to page 1 on filter change\n</code></pre></p> <p>Debounce Cleanup: <pre><code>useEffect(() => {\n return () => clearTimeout(searchTimerRef.current);\n}, []);\n</code></pre></p>"},{"location":"v2/frontend/pages/admin/email-templates-page/#helper-functions","title":"Helper Functions","text":"<p>Category Color Mapping: <pre><code>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</code></pre></p> <p>Open Modal/Drawer: <pre><code>const openTestEmailModal = (template: EmailTemplate) => {\n setSelectedTemplate(template);\n setTestModalOpen(true);\n};\n\nconst openVersionDrawer = (template: EmailTemplate) => {\n setSelectedTemplate(template);\n setVersionDrawerOpen(true);\n};\n</code></pre></p> <p>Delete Handler: <pre><code>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</code></pre></p>"},{"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":"<p>GET <code>/email-templates</code> - List templates with filters <pre><code>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</code></pre></p> <p>Response: <pre><code>{\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</code></pre></p> <p>DELETE <code>/email-templates/:id</code> - Delete template <pre><code>await api.delete(`/email-templates/${id}`);\n</code></pre></p> <p>Response: <code>204 No Content</code> on success</p>"},{"location":"v2/frontend/pages/admin/email-templates-page/#code-examples","title":"Code Examples","text":""},{"location":"v2/frontend/pages/admin/email-templates-page/#debounced-search-implementation","title":"Debounced Search Implementation","text":"<pre><code>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</code></pre> <p>Why 300ms? Standard debounce for search inputs balances responsiveness with API efficiency.</p>"},{"location":"v2/frontend/pages/admin/email-templates-page/#conditional-delete-button","title":"Conditional Delete Button","text":"<pre><code>{!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</code></pre> <p>Pattern: Hide delete button entirely for system templates rather than showing disabled button (clearer UI).</p>"},{"location":"v2/frontend/pages/admin/email-templates-page/#modal-openclose-pattern","title":"Modal Open/Close Pattern","text":"<pre><code>// 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</code></pre> <p>Pattern: Conditional rendering with <code>selectedTemplate &&</code> prevents rendering modal with null template.</p>"},{"location":"v2/frontend/pages/admin/email-templates-page/#category-color-function","title":"Category Color Function","text":"<pre><code>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</code></pre>"},{"location":"v2/frontend/pages/admin/email-templates-page/#relative-time-with-dayjs","title":"Relative Time with dayjs","text":"<pre><code>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</code></pre>"},{"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":"<p>Problem: Typing in search input triggers API call on every keystroke (excessive network traffic).</p> <p>Solution: 300ms debounce timer delays API call until user stops typing.</p> <p>Implementation: <pre><code>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</code></pre></p> <p>Benefit: Reduces API calls by ~80% for typical search behavior.</p>"},{"location":"v2/frontend/pages/admin/email-templates-page/#usecallback-for-fetch-function","title":"useCallback for Fetch Function","text":"<pre><code>const fetchTemplates = useCallback(\n async (params?: EmailTemplatesListParams) => { /* ... */ },\n [debouncedSearch, categoryFilter, activeFilter]\n);\n</code></pre> <p>Why: Prevents infinite re-render loop when <code>fetchTemplates</code> is used in <code>useEffect</code> dependency array.</p>"},{"location":"v2/frontend/pages/admin/email-templates-page/#table-pagination","title":"Table Pagination","text":"<p>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</p> <p>Benefit: Handles large template libraries without performance degradation.</p>"},{"location":"v2/frontend/pages/admin/email-templates-page/#component-conditional-rendering","title":"Component Conditional Rendering","text":"<pre><code>{selectedTemplate && (\n <TestEmailModal\n open={testModalOpen}\n template={selectedTemplate}\n onClose={closeTestEmailModal}\n />\n)}\n</code></pre> <p>Why: Modal only mounted when needed, saves memory and avoids rendering with null props.</p>"},{"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":"<p>Column Configuration: <pre><code>{\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</code></pre></p> <p>Mobile View (< 768px): - Visible: Name, Actions - Hidden: Category, Subject, Active, Updated</p> <p>Tablet View (768px - 991px): - Visible: Name, Category, Active, Updated, Actions - Hidden: Subject</p> <p>Desktop View (\u2265 992px): - All columns visible</p>"},{"location":"v2/frontend/pages/admin/email-templates-page/#filter-layout","title":"Filter Layout","text":"<pre><code><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</code></pre> <p><code>flexWrap: 'wrap'</code> ensures filters stack vertically on narrow screens.</p>"},{"location":"v2/frontend/pages/admin/email-templates-page/#table-scroll","title":"Table Scroll","text":"<pre><code><Table\n scroll={{ x: 'max-content' }}\n/>\n</code></pre> <p>Horizontal scroll on mobile prevents column squishing.</p>"},{"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":"<ul> <li>Tab: Navigate through search input, filter selects, action buttons</li> <li>Enter: Activate focused button (Edit, Test, Versions, Delete)</li> <li>Escape: Close open modals/drawers</li> <li>Arrow keys: Navigate table rows (native Ant Design behavior)</li> </ul>"},{"location":"v2/frontend/pages/admin/email-templates-page/#icon-labels","title":"Icon Labels","text":"<p>All icon-only buttons have text labels: <pre><code><Button icon={<EditOutlined />}>Edit</Button>\n<Button icon={<MailOutlined />}>Test</Button>\n<Button icon={<HistoryOutlined />}>Versions</Button>\n</code></pre></p> <p>Not icon-only buttons \u2013 clear action labels improve accessibility.</p>"},{"location":"v2/frontend/pages/admin/email-templates-page/#search-input_1","title":"Search Input","text":"<pre><code><Input\n placeholder=\"Search by name or key...\"\n prefix={<SearchOutlined />}\n allowClear\n/>\n</code></pre> <ul> <li>Placeholder text: Describes what to search for</li> <li>Prefix icon: Visual search indicator</li> <li>allowClear: X button to clear input (keyboard accessible)</li> </ul>"},{"location":"v2/frontend/pages/admin/email-templates-page/#popconfirm-for-destructive-actions","title":"Popconfirm for Destructive Actions","text":"<pre><code><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</code></pre> <p>Two-step confirmation prevents accidental deletion (important for accessibility and safety).</p>"},{"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":"<p>Symptoms: - Empty table - Loading spinner never stops - Error message \"Failed to load templates\"</p> <p>Causes: 1. API server not running (port 4000) 2. Network error 3. Missing authentication token 4. Database connection issue</p> <p>Solutions: <pre><code># 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</code></pre></p>"},{"location":"v2/frontend/pages/admin/email-templates-page/#search-not-working","title":"Search Not Working","text":"<p>Symptoms: - Typing in search input doesn't filter results - Search triggers on every keystroke (should debounce)</p> <p>Causes: 1. Debounce timer not clearing properly 2. <code>debouncedSearch</code> state not updating 3. API not receiving search param</p> <p>Debug: <pre><code>// 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</code></pre></p>"},{"location":"v2/frontend/pages/admin/email-templates-page/#delete-button-missing","title":"Delete Button Missing","text":"<p>Symptoms: - Delete button not visible for some templates</p> <p>Cause: - Template is a system template (<code>isSystem: true</code>)</p> <p>Expected Behavior: - System templates cannot be deleted (protected) - Delete button intentionally hidden for system templates</p> <p>Verification: <pre><code>// Check template data\nconsole.log('Template:', record.isSystem);\n// If true, delete button correctly hidden\n</code></pre></p>"},{"location":"v2/frontend/pages/admin/email-templates-page/#modaldrawer-not-opening","title":"Modal/Drawer Not Opening","text":"<p>Symptoms: - Clicking Test or Versions button does nothing - Modal/drawer remains closed</p> <p>Causes: 1. <code>selectedTemplate</code> is null 2. State update not triggering 3. Modal/drawer component not rendered</p> <p>Debug: <pre><code>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</code></pre></p>"},{"location":"v2/frontend/pages/admin/email-templates-page/#pagination-not-working","title":"Pagination Not Working","text":"<p>Symptoms: - Clicking page numbers doesn't load new data - Page stays on 1</p> <p>Cause: - <code>handleTableChange</code> not wired correctly - Pagination params not passed to API</p> <p>Debug: <pre><code>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</code></pre></p>"},{"location":"v2/frontend/pages/admin/email-templates-page/#subject-line-truncation","title":"Subject Line Truncation","text":"<p>Symptoms: - Long subject lines cut off without ellipsis</p> <p>Cause: - CSS ellipsis not applied</p> <p>Fix: <pre><code>render: (subject: string) => (\n <Text ellipsis style={{ maxWidth: 300 }}>\n {subject.length > 50 ? `${subject.slice(0, 50)}...` : subject}\n </Text>\n)\n</code></pre></p> <p>Alternative: Use Ant Design <code>Typography.Text</code> with <code>ellipsis</code> prop for automatic truncation.</p>"},{"location":"v2/frontend/pages/admin/email-templates-page/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/frontend/pages/admin/email-templates-page/#backend-integration","title":"Backend Integration","text":"<ul> <li>Email Templates Module - Service, schemas, routes</li> <li>Email Templates API Reference - Full endpoint documentation</li> </ul>"},{"location":"v2/frontend/pages/admin/email-templates-page/#frontend-components","title":"Frontend Components","text":"<ul> <li>TestEmailModal Component - Test email modal</li> <li>VersionHistoryDrawer Component - Version history drawer</li> </ul>"},{"location":"v2/frontend/pages/admin/email-templates-page/#editor-page","title":"Editor Page","text":"<ul> <li>Email Template Editor Page - Monaco editor for templates</li> </ul>"},{"location":"v2/frontend/pages/admin/email-templates-page/#features_1","title":"Features","text":"<ul> <li>Email Template System - Feature overview</li> <li>Template Variables - Variable system documentation</li> <li>Template Versioning - Version control system</li> </ul>"},{"location":"v2/frontend/pages/admin/email-templates-page/#user-guides","title":"User Guides","text":"<ul> <li>Admin Guide - Email Templates - Template management workflows</li> <li>Campaign Manager Guide - Using templates in campaigns</li> </ul>"},{"location":"v2/frontend/pages/admin/email-templates-page/#troubleshooting_1","title":"Troubleshooting","text":"<ul> <li>Email Issues - Email delivery troubleshooting</li> <li>Common Errors - General error resolution</li> </ul>"},{"location":"v2/frontend/pages/admin/email-templates-page/#related-pages","title":"Related Pages","text":"<ul> <li>CampaignsPage - Campaign management (uses email templates)</li> <li>ShiftsPage - Shift management (uses email templates)</li> <li>SettingsPage - Global settings including email configuration</li> </ul>"},{"location":"v2/frontend/pages/admin/gitea-page/","title":"GiteaPage","text":""},{"location":"v2/frontend/pages/admin/gitea-page/#overview","title":"Overview","text":"<p>File: <code>admin/src/pages/GiteaPage.tsx</code></p> <p>Route: <code>/app/services/gitea</code></p> <p>Role Requirements: Any authenticated user</p> <p>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.</p> <p>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</p> <p>Layout: AppLayout with fullbleed</p>"},{"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":"<p>Status Display: - Green \"Online\" badge when Gitea is accessible - Red \"Offline\" badge when unavailable - Blue \"Checking...\" badge during status check</p>"},{"location":"v2/frontend/pages/admin/gitea-page/#2-mobile-device-detection","title":"2. Mobile Device Detection","text":"<p>Mobile Warning: - BranchesOutlined icon (48px) - Message: \"The Git repository browser requires a desktop browser\" - \"Open in New Tab\" button for external access</p>"},{"location":"v2/frontend/pages/admin/gitea-page/#3-git-repository-management","title":"3. Git Repository Management","text":"<p>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</p>"},{"location":"v2/frontend/pages/admin/gitea-page/#component-structure","title":"Component Structure","text":"<pre><code>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</code></pre>"},{"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":"<ol> <li>GET /api/services/status - Check Gitea health</li> <li>GET /api/services/config - Fetch subdomain/port config</li> </ol>"},{"location":"v2/frontend/pages/admin/gitea-page/#example-responses","title":"Example Responses","text":"<p>Status: <pre><code>{\n \"gitea\": { \"online\": true }\n}\n</code></pre></p> <p>Config: <pre><code>{\n \"domain\": \"cmlite.org\",\n \"giteaSubdomain\": \"git\",\n \"giteaPort\": 3030\n}\n</code></pre></p> <p>Service URL: - Production: <code>http://git.cmlite.org</code> - Development: <code>http://localhost:3030</code></p>"},{"location":"v2/frontend/pages/admin/gitea-page/#user-workflow","title":"User Workflow","text":""},{"location":"v2/frontend/pages/admin/gitea-page/#accessing-gitea","title":"Accessing Gitea","text":"<ol> <li>Navigate to \"Services\" \u2192 \"Git Repository\" in sidebar</li> <li>Check status badge (Online/Offline)</li> <li>View Gitea interface in iframe</li> <li>Or click \"Open in New Tab\" for full window</li> </ol>"},{"location":"v2/frontend/pages/admin/gitea-page/#common-use-cases","title":"Common Use Cases","text":"<p>Repository Management: - Create new repository for project - Clone repository URL for local development - Browse code, commits, branches</p> <p>Collaboration: - Create issues for bugs/features - Submit pull requests for code review - Comment on code changes - Merge approved pull requests</p> <p>Documentation: - Edit project wiki - Update README files - Maintain changelog</p>"},{"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":"<p>Solutions:</p> <ol> <li> <p>Check Docker container: <pre><code>docker compose ps gitea\n</code></pre></p> </li> <li> <p>Check logs: <pre><code>docker compose logs gitea\n</code></pre></p> </li> <li> <p>Restart service: <pre><code>docker compose restart gitea\n</code></pre></p> </li> </ol>"},{"location":"v2/frontend/pages/admin/gitea-page/#problem-login-required","title":"Problem: Login Required","text":"<p>Symptoms: Iframe shows Gitea login screen</p> <p>Solutions:</p> <ol> <li>Check Gitea credentials in <code>.env</code>:</li> <li><code>GITEA_ADMIN_USER</code></li> <li> <p><code>GITEA_ADMIN_PASSWORD</code></p> </li> <li> <p>Login manually with admin credentials</p> </li> <li> <p>Create user account if needed</p> </li> </ol>"},{"location":"v2/frontend/pages/admin/gitea-page/#related-documentation","title":"Related Documentation","text":"<ul> <li>Gitea Setup - Docker configuration</li> <li>Git Workflow - Repository management</li> <li>Services API - Status endpoints</li> </ul>"},{"location":"v2/frontend/pages/admin/landing-pages-page/","title":"LandingPagesPage","text":""},{"location":"v2/frontend/pages/admin/landing-pages-page/#overview","title":"Overview","text":"<p>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 (<code>/p/:slug</code>) 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).</p> <p>Route: <code>/app/pages</code> Component: <code>admin/src/pages/LandingPagesPage.tsx</code> (510 lines) Auth Required: Yes (All authenticated users can view; editing requires appropriate role) Layout: AppLayout Backend Module: <code>api/src/modules/pages/</code></p>"},{"location":"v2/frontend/pages/admin/landing-pages-page/#screenshot","title":"Screenshot","text":"<p>[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.]</p>"},{"location":"v2/frontend/pages/admin/landing-pages-page/#features","title":"Features","text":"<ul> <li>Paginated table \u2014 Browse all landing pages with configurable page size (20, 50, 100)</li> <li>Search functionality \u2014 Search by title or description (300ms debounce)</li> <li>Status filtering \u2014 Filter by published/draft status</li> <li>Editor mode selection \u2014 Choose between Visual (GrapesJS) or Code editor</li> <li>CRUD operations \u2014 Create, edit, delete landing pages</li> <li>Publish/unpublish toggle \u2014 Control page visibility without deleting</li> <li>MkDocs integration \u2014 Export pages to MkDocs site with Material theme</li> <li>MkDocs synchronization \u2014 Import existing override files as page stubs</li> <li>Export validation \u2014 Detect and repair missing MkDocs export files</li> <li>Site building \u2014 Build MkDocs site directly from page list (SUPER_ADMIN only)</li> <li>SEO metadata \u2014 Configure title, description, and image for each page</li> <li>Custom MkDocs paths \u2014 Override default path (e.g., \"about.html\" instead of \"/p/about\")</li> <li>Standalone mode \u2014 Publish full HTML page without MkDocs theme wrapper</li> <li>Theme customization \u2014 Hide navigation sidebar, hide table of contents</li> <li>Skip export option \u2014 Keep page only accessible via /p/:slug (not in MkDocs)</li> <li>Settings modal \u2014 Comprehensive page settings (metadata, MkDocs config, SEO)</li> </ul>"},{"location":"v2/frontend/pages/admin/landing-pages-page/#user-workflow","title":"User Workflow","text":""},{"location":"v2/frontend/pages/admin/landing-pages-page/#creating-a-new-landing-page","title":"Creating a New Landing Page","text":"<ol> <li>Navigate to <code>/app/pages</code></li> <li>Click \"Create Page\" button (top-right, primary blue)</li> <li>Modal appears: \"Create Landing Page\"</li> <li>Fill in fields:</li> <li>Title: (required) e.g., \"About Our Campaign\"</li> <li>Description: (optional) e.g., \"Learn about our mission and values\"</li> <li>Editor Mode: (required) Choose \"Visual Editor\" or \"Code Editor\"</li> <li>Click \"Create & Edit\" button</li> <li>Page created in database (slug auto-generated from title)</li> <li>Navigates to <code>/app/pages/:id/edit</code> (page editor)</li> <li>Begin editing page content in chosen editor</li> </ol> <p>Editor Mode Selection:</p> <ul> <li>Visual Editor (default):</li> <li>GrapesJS drag-and-drop builder</li> <li>No coding required</li> <li>Pre-built components (text, image, button, form, etc.)</li> <li> <p>Best for non-technical users</p> </li> <li> <p>Code Editor:</p> </li> <li>Raw HTML/CSS/JS editor with Monaco</li> <li>Full control over markup</li> <li>Syntax highlighting</li> <li>Best for technical users</li> </ul> <p>Slug Generation:</p> <p>Title \"About Our Campaign\" \u2192 Slug \"about-our-campaign\"</p> <ul> <li>Lowercase</li> <li>Spaces \u2192 hyphens</li> <li>Special characters removed</li> <li>Unique (appends -2, -3 if duplicate)</li> </ul>"},{"location":"v2/frontend/pages/admin/landing-pages-page/#editing-an-existing-page","title":"Editing an Existing Page","text":"<ol> <li>Locate page in table</li> <li>Click Edit icon (pencil) in Actions column</li> <li>Navigates to <code>/app/pages/:id/edit</code></li> <li>Opens GrapesJS editor (visual) or Monaco editor (code)</li> <li>Make changes to page content</li> <li>Press Ctrl+S (or click Save button in editor)</li> <li>Changes auto-saved to database</li> <li>Return to page list: Click browser back button or navigate to <code>/app/pages</code></li> </ol>"},{"location":"v2/frontend/pages/admin/landing-pages-page/#publishing-a-page","title":"Publishing a Page","text":"<ol> <li>Locate draft page in table (Status: \"Draft\" gray tag)</li> <li>Click \"Publish\" button in Actions column</li> <li>API request updates <code>published: true</code></li> <li>Success message: \"Page published\"</li> <li>Table refreshes to show Status: \"Published\" (green tag)</li> <li>Effects of publishing:</li> <li>Page becomes visible at <code>/p/:slug</code> (public access)</li> <li>If not skipped, page exported to MkDocs site as override file</li> <li>Page appears in MkDocs navigation (if configured)</li> <li>SEO metadata becomes active</li> </ol>"},{"location":"v2/frontend/pages/admin/landing-pages-page/#unpublishing-a-page","title":"Unpublishing a Page","text":"<ol> <li>Locate published page in table (Status: \"Published\" green tag)</li> <li>Click \"Unpublish\" button in Actions column</li> <li>Confirmation: No confirmation dialog (immediate action)</li> <li>API request updates <code>published: false</code></li> <li>Success message: \"Page unpublished\"</li> <li>Table refreshes to show Status: \"Draft\" (gray tag)</li> <li>Effects of unpublishing:</li> <li>Page no longer accessible at <code>/p/:slug</code> (404 error)</li> <li>MkDocs export file remains (but page not linked)</li> <li>Page removed from MkDocs navigation</li> <li>SEO metadata inactive</li> </ol> <p>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</p>"},{"location":"v2/frontend/pages/admin/landing-pages-page/#viewing-a-published-page","title":"Viewing a Published Page","text":"<ol> <li>Locate published page in table</li> <li>Click Eye icon in Actions column</li> <li>Opens page in new browser tab: <code>/p/:slug</code></li> <li>View page as public user sees it</li> <li>Close tab to return to admin</li> </ol> <p>Note: Eye icon only visible for published pages (unpublished pages return 404).</p>"},{"location":"v2/frontend/pages/admin/landing-pages-page/#configuring-page-settings","title":"Configuring Page Settings","text":"<ol> <li>Locate page in table</li> <li>Click Settings icon (gear) in Actions column</li> <li>Modal appears: \"Page Settings\"</li> <li>Configure settings (see settings modal sections below)</li> <li>Click \"Save\" button</li> <li>API request updates page metadata</li> <li>Success message: \"Page settings updated\"</li> <li>Table refreshes to show updated values</li> </ol> <p>Settings Modal Sections:</p> <p>Basic Settings: - Title (required) - Description (optional)</p> <p>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":"<p>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.</p> <p>Route: <code>/app/listmonk</code> Component: <code>admin/src/pages/ListmonkPage.tsx</code> (395 lines) Auth Required: Yes (SUPER_ADMIN or INFLUENCE_ADMIN role recommended) Layout: AppLayout Backend Module: <code>api/src/modules/listmonk/</code></p>"},{"location":"v2/frontend/pages/admin/listmonk-page/#screenshot","title":"Screenshot","text":"<p>[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.]</p>"},{"location":"v2/frontend/pages/admin/listmonk-page/#features","title":"Features","text":"<ul> <li>Dual-view interface \u2014 Tab switcher between Management and Listmonk Admin</li> <li>Status monitoring \u2014 Real-time sync status, connection state, initialization status</li> <li>Selective synchronization \u2014 Sync participants, locations, or users individually</li> <li>Bulk synchronization \u2014 Sync all lists at once</li> <li>Connection testing \u2014 Test Listmonk API connectivity before syncing</li> <li>List statistics \u2014 Subscriber counts for each list (Participants, Locations, Users)</li> <li>Advanced operations \u2014 Reinitialize lists if corrupted or missing</li> <li>Embedded Listmonk admin \u2014 Full Listmonk UI loaded in iframe with auto-authentication</li> <li>External Listmonk access \u2014 Open Listmonk in new tab (direct access on port 9001)</li> <li>Error reporting \u2014 Display last sync error with timestamp</li> <li>Last sync tracking \u2014 Relative time since last successful sync</li> <li>Sync failure counts \u2014 Track failed subscriber additions (shown in warnings)</li> <li>Fullbleed iframe \u2014 Listmonk Admin tab removes padding for full-screen experience</li> </ul>"},{"location":"v2/frontend/pages/admin/listmonk-page/#user-workflow","title":"User Workflow","text":""},{"location":"v2/frontend/pages/admin/listmonk-page/#checking-sync-status","title":"Checking Sync Status","text":"<ol> <li>Navigate to <code>/app/listmonk</code></li> <li>Ensure \"Management\" tab is selected (default)</li> <li>Observe \"Status\" card (left column):</li> <li>Sync Enabled: Badge shows Enabled (green) or Disabled (red)</li> <li>Connection: Badge shows Connected (green), Disconnected (orange), or N/A (gray)</li> <li>Lists Initialized: Badge shows Yes (green) or No (gray)</li> <li>Last Sync: Relative time (e.g., \"2 minutes ago\") or \"Never\"</li> <li>Last Error: Error message or \"None\"</li> <li>Check \"List Statistics\" table:</li> <li>Participants: Subscriber count for campaign participants</li> <li>Locations: Subscriber count for map locations</li> <li>Users: Subscriber count for user accounts</li> </ol> <p>Sync Enabled States: - Enabled (green): <code>LISTMONK_SYNC_ENABLED=true</code> in .env, sync operations allowed - Disabled (red): <code>LISTMONK_SYNC_ENABLED=false</code> in .env, sync operations blocked</p> <p>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</p> <p>Lists Initialized: - Yes (green): Listmonk lists (Participants, Locations, Users) exist and ready - No (gray): Lists not yet created (click \"Reinitialize Lists\" to create)</p>"},{"location":"v2/frontend/pages/admin/listmonk-page/#testing-listmonk-connection","title":"Testing Listmonk Connection","text":"<p>When to Test: - Before first sync (verify credentials) - After updating Listmonk URL/credentials - Troubleshooting sync failures</p> <p>Steps:</p> <ol> <li>Click \"Test Connection\" button (top-right header)</li> <li>Loading spinner appears on button</li> <li>Backend tests Listmonk API connection:</li> <li>GET <code>/api/health</code> endpoint</li> <li>Verifies basic auth credentials</li> <li>Checks API version compatibility</li> <li>Result message appears:</li> <li>Success: \"Connection successful\" (green toast)</li> <li>Warning: \"Connection partially successful - check configuration\" (orange toast)</li> <li>Error: \"Connection failed - check Listmonk URL and credentials\" (red toast)</li> <li>Status card refreshes to show updated connection state</li> </ol> <p>Success Criteria: - Listmonk API responds to /api/health endpoint - Credentials (username/password) authenticate successfully - API version is compatible (v2.0+)</p>"},{"location":"v2/frontend/pages/admin/listmonk-page/#syncing-participants-to-listmonk","title":"Syncing Participants to Listmonk","text":"<p>What is \"Participants\"?</p> <p>Campaign participants who submitted responses via the response wall. Synced to Listmonk \"Participants\" list for newsletter targeting.</p> <p>Steps:</p> <ol> <li>Click \"Sync Participants\" button in \"Sync Actions\" card</li> <li>Loading spinner appears on button</li> <li>Backend fetches all campaign participants from database:</li> <li>Query: <code>SELECT DISTINCT email, name FROM Response WHERE verified = true</code></li> <li>Filter: Only verified responses (email confirmed)</li> <li>For each participant:</li> <li>Check if subscriber exists in Listmonk \"Participants\" list</li> <li>If not exists, create new subscriber with name and email</li> <li>If exists, update subscriber attributes (last campaign, response count)</li> <li>Result message appears:</li> <li>Success: \"Synced participants: 347 created, 23 updated\"</li> <li>Warning: \"Synced participants: 347 created, 23 updated, 5 failed - check logs\"</li> <li>Status card and list statistics update to show new counts</li> </ol> <p>Sync Logic:</p> <pre><code>// 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</code></pre>"},{"location":"v2/frontend/pages/admin/listmonk-page/#syncing-locations-to-listmonk","title":"Syncing Locations to Listmonk","text":"<p>What is \"Locations\"?</p> <p>Map locations (residential addresses, campaign offices, etc.). Synced to Listmonk \"Locations\" list for geographic targeting.</p> <p>Steps:</p> <ol> <li>Click \"Sync Locations\" button in \"Sync Actions\" card</li> <li>Loading spinner appears on button</li> <li>Backend fetches all locations with valid email addresses:</li> <li>Query: <code>SELECT * FROM Location WHERE email IS NOT NULL AND deletedAt IS NULL</code></li> <li>Filter: Only locations with email, not soft-deleted</li> <li>For each location:</li> <li>Check if subscriber exists in Listmonk \"Locations\" list</li> <li>If not exists, create new subscriber with address details</li> <li>If exists, update subscriber attributes (address, postal code, cut)</li> <li>Result message appears:</li> <li>Success: \"Synced locations: 203 created, 45 updated\"</li> <li>Status card and list statistics update</li> </ol> <p>Subscriber Attributes:</p> <pre><code>{\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</code></pre>"},{"location":"v2/frontend/pages/admin/listmonk-page/#syncing-users-to-listmonk","title":"Syncing Users to Listmonk","text":"<p>What is \"Users\"?</p> <p>User accounts (admins, volunteers, etc.). Synced to Listmonk \"Users\" list for internal communications.</p> <p>Steps:</p> <ol> <li>Click \"Sync Users\" button in \"Sync Actions\" card</li> <li>Loading spinner appears on button</li> <li>Backend fetches all user accounts:</li> <li>Query: <code>SELECT * FROM User WHERE deletedAt IS NULL</code></li> <li>Filter: Exclude soft-deleted users</li> <li>For each user:</li> <li>Check if subscriber exists in Listmonk \"Users\" list</li> <li>If not exists, create new subscriber with role info</li> <li>If exists, update subscriber attributes (role, last login)</li> <li>Result message appears:</li> <li>Success: \"Synced users: 15 created, 3 updated\"</li> <li>Status card and list statistics update</li> </ol> <p>Subscriber Attributes:</p> <pre><code>{\n email: user.email,\n name: user.name,\n lists: [usersListId],\n attribs: {\n role: user.role,\n lastLogin: user.lastLogin,\n },\n}\n</code></pre>"},{"location":"v2/frontend/pages/admin/listmonk-page/#syncing-all-lists-at-once","title":"Syncing All Lists at Once","text":"<p>When to Use: - Initial setup (populate all lists) - After bulk data import (NAR import, CSV import) - Regular maintenance (weekly/monthly sync)</p> <p>Steps:</p> <ol> <li>Click \"Sync All\" button (primary blue, bottom-right of \"Sync Actions\" card)</li> <li>Loading spinner appears on button</li> <li>Backend syncs all three lists sequentially:</li> <li>First: Sync participants</li> <li>Second: Sync locations</li> <li>Third: Sync users</li> <li>Result message shows aggregated counts:</li> <li>Success: \"Synced all lists: 347 participants, 203 locations, 15 users\"</li> <li>Warning: \"Synced all lists: 565 total, 8 failed - check logs\"</li> <li>Status card and list statistics update to show all new counts</li> </ol> <p>Performance:</p> <ul> <li>Sequential execution: Lists synced one at a time (not parallel)</li> <li>Duration: Typically 10-30 seconds for 500+ subscribers</li> <li>Idempotent: Safe to run multiple times (creates or updates, no duplicates)</li> </ul>"},{"location":"v2/frontend/pages/admin/listmonk-page/#reinitializing-listmonk-lists","title":"Reinitializing Listmonk Lists","text":"<p>When to Reinitialize: - Lists accidentally deleted in Listmonk - Fresh Listmonk installation - Corrupted list data</p> <p>Steps:</p> <ol> <li>Scroll to \"Advanced\" section at bottom</li> <li>Click to expand \"Advanced\" collapse panel</li> <li>Click \"Reinitialize Lists\" button</li> <li>Confirmation popconfirm appears: \"Reinitialize Lists. This will re-create any missing lists in Listmonk. Existing lists are preserved.\"</li> <li>Click \"Reinitialize\" to confirm (or click outside to cancel)</li> <li>Loading spinner appears on button</li> <li>Backend checks for existence of each list (Participants, Locations, Users)</li> <li>For each missing list:</li> <li>Create new list with name and type (public/private)</li> <li>Set list description</li> <li>Success message: \"Lists reinitialized\" (or error if creation fails)</li> <li>Status card updates to show \"Lists Initialized: Yes\"</li> </ol> <p>Important: Reinitialization only creates missing lists. Existing lists are NOT deleted or modified. Existing subscribers remain intact.</p>"},{"location":"v2/frontend/pages/admin/listmonk-page/#accessing-embedded-listmonk-admin","title":"Accessing Embedded Listmonk Admin","text":"<p>What is \"Listmonk Admin\" Tab?</p> <p>Full Listmonk web UI embedded in iframe, allowing direct management without leaving admin interface.</p> <p>Steps:</p> <ol> <li>Click \"Listmonk Admin\" button in tab switcher (top-right header)</li> <li>Active tab changes from \"Management\" to \"Listmonk Admin\"</li> <li>Page layout changes to fullbleed (removes padding for full-screen iframe)</li> <li>Loading spinner appears while iframe loads</li> <li>Backend generates auto-authentication token:</li> <li>GET <code>/api/listmonk/proxy-url</code></li> <li>Response: <code>{ port: 9001, token: \"auto-auth-token-xyz\" }</code></li> <li>Iframe loads Listmonk URL with auth token:</li> <li>URL: <code>//localhost:9001/auth?token=auto-auth-token-xyz</code></li> <li>Listmonk auto-authenticates user (no manual login required)</li> <li>Full Listmonk UI appears in iframe:</li> <li>Dashboard (campaign stats, subscriber counts)</li> <li>Campaigns (create/send newsletters)</li> <li>Subscribers (view/edit/import)</li> <li>Lists (manage subscriber lists)</li> <li>Templates (email templates with WYSIWYG editor)</li> </ol> <p>Use Cases: - Create newsletter campaigns - View/edit subscribers directly - Import subscribers from CSV - Design email templates - View campaign analytics</p> <p>Limitations: - Iframe may have slight performance overhead vs. direct access - Some Listmonk features may require full-screen (use \"Open Listmonk\" button instead)</p>"},{"location":"v2/frontend/pages/admin/listmonk-page/#opening-listmonk-in-new-tab","title":"Opening Listmonk in New Tab","text":"<p>When to Use: - Full-screen Listmonk access (no iframe constraints) - Better performance (no iframe overhead) - Working with large subscriber lists (better scrolling)</p> <p>Steps:</p> <ol> <li>Click \"Open Listmonk\" button (top-right header, next to \"Test Connection\")</li> <li>New browser tab opens with Listmonk URL: <code>//localhost:9001</code></li> <li>Listmonk login page appears (if not already logged in)</li> <li>Enter Listmonk admin credentials:</li> <li>Username: Value of <code>LISTMONK_WEB_ADMIN_USER</code> env var</li> <li>Password: Value of <code>LISTMONK_WEB_ADMIN_PASSWORD</code> env var</li> <li>Click \"Login\" to access full Listmonk interface</li> </ol> <p>Note: This opens Listmonk directly on port 9001. User must manually authenticate (no auto-auth token). Use this for full-featured access without iframe restrictions.</p>"},{"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":"<ul> <li>Typography.Text \u2014 Labels, descriptions</li> <li>Row / Col \u2014 Grid layout for status and sync action cards</li> <li>Card \u2014 Container for Status, Sync Actions, List Statistics</li> <li>Descriptions \u2014 Key-value pairs in Status card</li> <li>Descriptions.Item \u2014 Individual status fields</li> <li>Badge \u2014 Status indicators (Enabled/Disabled, Connected/Disconnected, Yes/No)</li> <li>Space \u2014 Button grouping</li> <li>Button \u2014 Sync buttons, Test Connection, Open Listmonk</li> <li>Radio.Group \u2014 Tab switcher (Management / Listmonk Admin)</li> <li>Radio.Button \u2014 Individual tab buttons</li> <li>Table \u2014 List statistics table</li> <li>Collapse \u2014 Advanced section (collapsible)</li> <li>Popconfirm \u2014 Reinitialize confirmation dialog</li> <li>Alert \u2014 Iframe error alert (if load fails)</li> <li>Spin \u2014 Loading indicators (initial load, iframe load, button actions)</li> <li>App.useApp \u2014 Access to message and modal contexts</li> <li>message \u2014 Toast notifications for success/error feedback</li> </ul>"},{"location":"v2/frontend/pages/admin/listmonk-page/#dual-view-tab-switcher","title":"Dual-View Tab Switcher","text":"<pre><code><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</code></pre> <p>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</p>"},{"location":"v2/frontend/pages/admin/listmonk-page/#status-card","title":"Status Card","text":"<pre><code><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</code></pre> <p>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</p>"},{"location":"v2/frontend/pages/admin/listmonk-page/#sync-actions-card","title":"Sync Actions Card","text":"<pre><code><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</code></pre> <p>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)</p>"},{"location":"v2/frontend/pages/admin/listmonk-page/#list-statistics-table","title":"List Statistics Table","text":"<pre><code><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</code></pre> <p>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</p>"},{"location":"v2/frontend/pages/admin/listmonk-page/#embedded-listmonk-iframe","title":"Embedded Listmonk Iframe","text":"<pre><code>{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</code></pre> <p>Iframe Features: - Full viewport height: <code>calc(100vh - 64px)</code> 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 (<code>?token=xyz</code>) logs user in automatically</p>"},{"location":"v2/frontend/pages/admin/listmonk-page/#state-management","title":"State Management","text":""},{"location":"v2/frontend/pages/admin/listmonk-page/#local-state-no-zustand-store","title":"Local State (No Zustand Store)","text":"<pre><code>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</code></pre> <p>State Variables: - <code>status</code> (object | null): Sync status (enabled, connected, initialized, lastSyncAt, lastError) - <code>stats</code> (object | null): List statistics (lists array with name and subscriberCount) - <code>loading</code> (boolean): Initial page load state - <code>syncing</code> (object): Sync button loading states (participants, locations, users, all, test, reinit) - <code>iframeSrc</code> (string | null): Listmonk iframe URL with auth token - <code>iframeLoading</code> (boolean): Iframe loading state - <code>iframeError</code> (string | null): Iframe load error message - <code>iframeInitialized</code> (ref): Prevents redundant iframe loads (only load once) - <code>activeTab</code> (string): Currently active tab ('management' or 'admin')</p> <p>No Global State:</p> <p>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</p>"},{"location":"v2/frontend/pages/admin/listmonk-page/#lazy-iframe-loading","title":"Lazy Iframe Loading","text":"<p>Iframe only loads when Admin tab is selected:</p> <pre><code>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</code></pre> <p>Why Lazy Load?</p> <ul> <li>Performance: Iframe not loaded until needed (saves memory + network)</li> <li>User experience: Most users stay on Management tab (no iframe overhead)</li> <li>One-time load: <code>iframeInitialized</code> ref prevents redundant loads</li> </ul>"},{"location":"v2/frontend/pages/admin/listmonk-page/#fullbleed-layout-for-iframe","title":"Fullbleed Layout for Iframe","text":"<p>When Admin tab is active, page header sets <code>fullBleed: true</code>:</p> <pre><code>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</code></pre> <p>Result:</p> <ul> <li>Management tab: Normal padding (comfortable reading)</li> <li>Admin tab: No padding (iframe fills entire content area)</li> </ul>"},{"location":"v2/frontend/pages/admin/listmonk-page/#api-integration","title":"API Integration","text":""},{"location":"v2/frontend/pages/admin/listmonk-page/#endpoints-used","title":"Endpoints Used","text":"Method Endpoint Purpose Auth GET <code>/api/listmonk</code> Get sync status Required GET <code>/api/listmonk/stats</code> Get list statistics Required POST <code>/api/listmonk/test-connection</code> Test Listmonk API connection Required POST <code>/api/listmonk/sync/participants</code> Sync participants list Required POST <code>/api/listmonk/sync/locations</code> Sync locations list Required POST <code>/api/listmonk/sync/users</code> Sync users list Required POST <code>/api/listmonk/sync/all</code> Sync all lists Required POST <code>/api/listmonk/reinitialize</code> Reinitialize lists Required GET <code>/api/listmonk/proxy-url</code> Get iframe URL with auth token Required"},{"location":"v2/frontend/pages/admin/listmonk-page/#load-sync-status","title":"Load Sync Status","text":"<p>Request:</p> <pre><code>const { data } = await api.get<ListmonkStatus>('/listmonk');\n</code></pre> <p>Response (200 OK):</p> <pre><code>{\n \"enabled\": true,\n \"connected\": true,\n \"initialized\": true,\n \"lastSyncAt\": \"2026-02-11T10:30:00.000Z\",\n \"lastError\": null\n}\n</code></pre> <p>Response Fields: - <code>enabled</code> (boolean): Value of <code>LISTMONK_SYNC_ENABLED</code> env var - <code>connected</code> (boolean): Listmonk API reachable and credentials valid - <code>initialized</code> (boolean): Listmonk lists (Participants, Locations, Users) exist - <code>lastSyncAt</code> (ISO 8601 | null): Timestamp of last successful sync - <code>lastError</code> (string | null): Last error message (or null if no errors)</p>"},{"location":"v2/frontend/pages/admin/listmonk-page/#load-list-statistics","title":"Load List Statistics","text":"<p>Request:</p> <pre><code>const { data } = await api.get<ListmonkStats>('/listmonk/stats');\n</code></pre> <p>Response (200 OK):</p> <pre><code>{\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</code></pre> <p>Response Fields: - <code>lists</code> (array): Array of list objects - <code>name</code> (string): List name (Participants, Locations, or Users) - <code>subscriberCount</code> (number): Number of subscribers in list</p> <p>Backend Calculation:</p> <pre><code>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</code></pre>"},{"location":"v2/frontend/pages/admin/listmonk-page/#test-listmonk-connection","title":"Test Listmonk Connection","text":"<p>Request:</p> <pre><code>const { data } = await api.post<{ success: boolean; message: string }>('/listmonk/test-connection');\n</code></pre> <p>Response (200 OK) - Success:</p> <pre><code>{\n \"success\": true,\n \"message\": \"Connection successful - Listmonk v2.3.0\"\n}\n</code></pre> <p>Response (200 OK) - Failure:</p> <pre><code>{\n \"success\": false,\n \"message\": \"Connection failed: Authentication error\"\n}\n</code></pre> <p>Response Fields: - <code>success</code> (boolean): Whether connection test passed - <code>message</code> (string): Result message (success details or error reason)</p> <p>Backend Test:</p> <pre><code>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</code></pre>"},{"location":"v2/frontend/pages/admin/listmonk-page/#sync-participantslocationsusers","title":"Sync Participants/Locations/Users","text":"<p>Request:</p> <pre><code>const type = 'participants'; // or 'locations' or 'users'\nconst { data } = await api.post<ListmonkSyncResult>(`/listmonk/sync/${type}`);\n</code></pre> <p>Response (200 OK):</p> <pre><code>{\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</code></pre> <p>Response Fields: - <code>success</code> (boolean): Whether sync operation completed - <code>message</code> (string): Result summary - <code>results</code> (object): - <code>created</code> (number): New subscribers added - <code>updated</code> (number): Existing subscribers updated - <code>failed</code> (number): Subscribers that failed to sync (API errors, validation errors)</p> <p>Backend Workflow:</p> <pre><code>// 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</code></pre>"},{"location":"v2/frontend/pages/admin/listmonk-page/#sync-all-lists","title":"Sync All Lists","text":"<p>Request:</p> <pre><code>const { data } = await api.post<ListmonkSyncAllResult>('/listmonk/sync/all');\n</code></pre> <p>Response (200 OK):</p> <pre><code>{\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</code></pre> <p>Response Fields: - <code>success</code> (boolean): Whether all syncs completed - <code>message</code> (string): Result summary - <code>results</code> (object): - <code>participants</code> (object): Participant sync results - <code>locations</code> (object): Location sync results - <code>users</code> (object): User sync results</p>"},{"location":"v2/frontend/pages/admin/listmonk-page/#reinitialize-lists","title":"Reinitialize Lists","text":"<p>Request:</p> <pre><code>await api.post('/listmonk/reinitialize');\n</code></pre> <p>Response (200 OK):</p> <pre><code>{\n \"message\": \"Lists reinitialized\"\n}\n</code></pre> <p>Backend Workflow:</p> <pre><code>// 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</code></pre>"},{"location":"v2/frontend/pages/admin/listmonk-page/#get-iframe-proxy-url","title":"Get Iframe Proxy URL","text":"<p>Request:</p> <pre><code>const { data } = await api.get<{ port: number; token: string }>('/listmonk/proxy-url');\n</code></pre> <p>Response (200 OK):</p> <pre><code>{\n \"port\": 9001,\n \"token\": \"auto-auth-token-abc123xyz\"\n}\n</code></pre> <p>Response Fields: - <code>port</code> (number): Listmonk service port (typically 9001) - <code>token</code> (string): Auto-authentication token (valid for 5 minutes)</p> <p>Backend Workflow:</p> <pre><code>// 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</code></pre> <p>Listmonk Auto-Authentication:</p> <p>Listmonk checks Redis for token when <code>/auth?token=xyz</code> is accessed:</p> <pre><code>// 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</code></pre>"},{"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":"<pre><code>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</code></pre> <p>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</p>"},{"location":"v2/frontend/pages/admin/listmonk-page/#sync-all-flow","title":"Sync All Flow","text":"<pre><code>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</code></pre> <p>Aggregate Failure Count:</p> <p>Sums failed count from all three lists (participants + locations + users) to show total failures.</p>"},{"location":"v2/frontend/pages/admin/listmonk-page/#lazy-iframe-loading_1","title":"Lazy Iframe Loading","text":"<pre><code>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</code></pre> <p>Lazy Loading Logic:</p> <ul> <li>First call: <code>iframeInitialized.current</code> is false, so iframe loads</li> <li>Subsequent calls: <code>iframeInitialized.current</code> is true, so function returns early (no redundant loads)</li> <li>Ref persists: <code>useRef</code> value persists across re-renders (unlike state)</li> </ul>"},{"location":"v2/frontend/pages/admin/listmonk-page/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/frontend/pages/admin/listmonk-page/#lazy-iframe-loading_2","title":"Lazy Iframe Loading","text":"<p>Iframe only loads when Admin tab is selected:</p> <pre><code>if (tab === 'admin') loadIframe();\n</code></pre> <p>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</p>"},{"location":"v2/frontend/pages/admin/listmonk-page/#parallel-status-and-stats-fetching","title":"Parallel Status and Stats Fetching","text":"<p>Status and stats fetched in parallel:</p> <pre><code>const fetchAll = useCallback(async () => {\n setLoading(true);\n await Promise.all([fetchStatus(), fetchStats()]);\n setLoading(false);\n}, [fetchStatus, fetchStats]);\n</code></pre> <p>Performance Impact: - Sequential: 200ms (status) + 200ms (stats) = 400ms total - Parallel: max(200ms, 200ms) = 200ms total - Result: 2\u00d7 faster initial page load</p>"},{"location":"v2/frontend/pages/admin/listmonk-page/#conditional-iframe-rendering","title":"Conditional Iframe Rendering","text":"<p>Iframe not rendered until tab is selected:</p> <pre><code>{activeTab === 'admin' && (\n <iframe src={iframeSrc} />\n)}\n</code></pre> <p>Performance Impact: - Always rendered: Iframe exists in DOM even when hidden (consumes memory) - Conditional: Iframe only exists in DOM when visible (no memory overhead)</p>"},{"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":"<p>Sync action buttons adapt to mobile viewports:</p> <pre><code><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</code></pre> <p>Responsive Grid: - Mobile (xs, <576px): Stacked buttons (full width) - Desktop (sm+, \u2265576px): 2\u00d72 grid (half width each)</p>"},{"location":"v2/frontend/pages/admin/listmonk-page/#iframe-height","title":"Iframe Height","text":"<p>Iframe fills available viewport height:</p> <pre><code><iframe\n src={iframeSrc}\n style={{\n height: 'calc(100vh - 64px)', // Full viewport height minus header\n }}\n/>\n</code></pre> <p>Calculation: - <code>100vh</code>: Full viewport height - <code>-64px</code>: Subtract header height (64px) - Result: Iframe fills entire content area below header</p>"},{"location":"v2/frontend/pages/admin/listmonk-page/#accessibility","title":"Accessibility","text":""},{"location":"v2/frontend/pages/admin/listmonk-page/#keyboard-navigation","title":"Keyboard Navigation","text":"<p>All interactive elements are keyboard-accessible:</p> <p>Tab Switcher: - Tab: Focus on tab switcher - Arrow Keys: Navigate between Management and Admin tabs - Enter/Space: Activate selected tab</p> <p>Buttons: - Tab: Move between buttons (Test Connection \u2192 Sync Participants \u2192 Sync Locations...) - Enter/Space: Activate focused button</p> <p>Iframe: - Tab: Focus moves into iframe (Listmonk UI is keyboard-accessible) - Shift+Tab: Focus moves out of iframe back to page controls</p>"},{"location":"v2/frontend/pages/admin/listmonk-page/#screen-reader-support","title":"Screen Reader Support","text":"<p>All elements have proper ARIA labels:</p> <p>Status Badges: <pre><code><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</code></pre></p> <p>Sync Buttons: <pre><code><Button\n icon={<SyncOutlined />}\n onClick={() => handleSync('participants')}\n aria-label=\"Sync participants to Listmonk Participants list\"\n>\n Sync Participants\n</Button>\n</code></pre></p>"},{"location":"v2/frontend/pages/admin/listmonk-page/#color-contrast","title":"Color Contrast","text":"<p>All color-coded elements meet WCAG AA standards:</p> <p>Status Badges: - Success (green dot): <code>#52c41a</code> = visible on all backgrounds - Warning (orange dot): <code>#faad14</code> = visible on all backgrounds - Error (red dot): <code>#ff4d4f</code> = visible on all backgrounds - Default (gray dot): <code>#d9d9d9</code> = visible on all backgrounds</p>"},{"location":"v2/frontend/pages/admin/listmonk-page/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/frontend/pages/admin/listmonk-page/#sync-disabled-buttons-grayed-out","title":"Sync Disabled (Buttons Grayed Out)","text":"<p>Problem: All sync buttons are grayed out (disabled state).</p> <p>Diagnosis:</p> <p>Check <code>.env</code> file:</p> <pre><code>grep LISTMONK_SYNC_ENABLED .env\n</code></pre> <p>Expected: <code>LISTMONK_SYNC_ENABLED=true</code></p> <p>Actual: <code>LISTMONK_SYNC_ENABLED=false</code> or missing</p> <p>Solution:</p> <ol> <li> <p>Edit <code>.env</code> file: <pre><code>nano .env\n</code></pre></p> </li> <li> <p>Add or update line: <pre><code>LISTMONK_SYNC_ENABLED=true\n</code></pre></p> </li> <li> <p>Restart API container: <pre><code>docker compose restart api\n</code></pre></p> </li> <li> <p>Refresh page to see enabled buttons</p> </li> </ol>"},{"location":"v2/frontend/pages/admin/listmonk-page/#connection-test-fails","title":"Connection Test Fails","text":"<p>Problem: Click \"Test Connection\", get error: \"Connection failed - check Listmonk URL and credentials\".</p> <p>Diagnosis:</p> <p>Check Listmonk container:</p> <pre><code>docker compose ps listmonk\n</code></pre> <p>Expected: STATUS = Up</p> <p>Check Listmonk logs:</p> <pre><code>docker compose logs listmonk\n</code></pre> <p>Common errors:</p> <pre><code>ERROR: Database connection failed\nERROR: Authentication failed for user \"api\"\n</code></pre> <p>Possible Causes:</p> <ol> <li>Listmonk container down:</li> <li>Service not running</li> <li> <p>Failed to start due to configuration error</p> </li> <li> <p>Wrong credentials:</p> </li> <li><code>LISTMONK_ADMIN_USER</code> or <code>LISTMONK_ADMIN_PASSWORD</code> incorrect</li> <li> <p>API user not created in Listmonk database</p> </li> <li> <p>Network issue:</p> </li> <li>API container cannot reach Listmonk container</li> <li>Docker network misconfigured</li> </ol> <p>Solution:</p> <ol> <li> <p>Start Listmonk: <pre><code>docker compose up -d listmonk\n</code></pre></p> </li> <li> <p>Verify credentials: <pre><code>grep LISTMONK_ .env\n</code></pre></p> </li> </ol> <p>Check that <code>LISTMONK_ADMIN_USER</code> and <code>LISTMONK_ADMIN_PASSWORD</code> match Listmonk configuration.</p> <ol> <li>Test connection manually: <pre><code>curl -u admin:password http://localhost:9001/api/health\n</code></pre></li> </ol> <p>Expected: <code>{\"version\":\"2.3.0\"}</code></p>"},{"location":"v2/frontend/pages/admin/listmonk-page/#lists-not-initialized","title":"Lists Not Initialized","text":"<p>Problem: Status shows \"Lists Initialized: No\".</p> <p>Diagnosis:</p> <p>Check Listmonk lists:</p> <pre><code>docker compose exec listmonk listmonk --dump-all-lists\n</code></pre> <p>Expected: Participants, Locations, Users lists present</p> <p>Actual: No lists found</p> <p>Solution:</p> <ol> <li>Click \"Advanced\" to expand advanced section</li> <li>Click \"Reinitialize Lists\" button</li> <li>Confirm reinitialize</li> <li>Wait for success message: \"Lists reinitialized\"</li> <li>Refresh page to see \"Lists Initialized: Yes\"</li> </ol>"},{"location":"v2/frontend/pages/admin/listmonk-page/#iframe-not-loading","title":"Iframe Not Loading","text":"<p>Problem: Click \"Listmonk Admin\" tab, but only see loading spinner or error message.</p> <p>Diagnosis:</p> <p>Check iframe error message in Alert:</p> <pre><code>Failed to load Listmonk admin \u2014 ensure the proxy is running\n</code></pre> <p>Check browser console for errors:</p> <pre><code>Refused to display 'http://localhost:9001' in a frame because it set 'X-Frame-Options' to 'SAMEORIGIN'\n</code></pre> <p>Possible Causes:</p> <ol> <li>Proxy URL endpoint failed:</li> <li>API cannot generate auto-auth token</li> <li> <p>Redis down (tokens stored in Redis)</p> </li> <li> <p>X-Frame-Options blocking:</p> </li> <li>Listmonk sets <code>X-Frame-Options: SAMEORIGIN</code></li> <li> <p>Browser blocks iframe from different origin</p> </li> <li> <p>CORS issue:</p> </li> <li>Listmonk does not allow iframe embedding from admin domain</li> </ol> <p>Solution:</p> <ol> <li>Check Redis: <pre><code>docker compose ps redis\ndocker compose exec redis redis-cli PING\n</code></pre></li> </ol> <p>Expected: \"PONG\"</p> <ol> <li>Use \"Open Listmonk\" button instead:</li> <li>Opens Listmonk in new tab (no iframe, no X-Frame-Options issue)</li> <li> <p>Manual login required (no auto-auth token)</p> </li> <li> <p>Configure Listmonk to allow iframes (developer fix):</p> </li> <li>Edit Listmonk nginx config</li> <li>Remove or modify <code>X-Frame-Options</code> header</li> <li>Restart Listmonk container</li> </ol>"},{"location":"v2/frontend/pages/admin/listmonk-page/#related-documentation","title":"Related Documentation","text":"<ul> <li>Listmonk Backend Module \u2014 Backend Listmonk service</li> <li>Listmonk Sync Service \u2014 Sync orchestration</li> <li>Listmonk Client \u2014 API client</li> <li>Listmonk API Reference \u2014 Listmonk endpoints</li> <li>Newsletter Feature \u2014 Newsletter system overview</li> <li>ResponsesPage \u2014 Campaign responses (synced to Listmonk)</li> <li>LocationsPage \u2014 Map locations (synced to Listmonk)</li> <li>UsersPage \u2014 User accounts (synced to Listmonk)</li> <li>Listmonk Documentation \u2014 Official Listmonk docs</li> <li>Troubleshooting: Listmonk Issues \u2014 Listmonk troubleshooting</li> </ul>"},{"location":"v2/frontend/pages/admin/locations-page/","title":"LocationsPage","text":""},{"location":"v2/frontend/pages/admin/locations-page/#overview","title":"Overview","text":"<p>The LocationsPage is the centerpiece of the Map module, providing comprehensive location database management with dual-tab view (table + interactive map), multi-format CSV import (standard, NAR upload, NAR server), multi-provider geocoding, bulk operations, location history tracking, and expandable address units for multi-unit buildings. This is the most feature-rich CRUD page in the admin interface, handling millions of location records for canvassing operations.</p> <p>Route: <code>/app/map/locations</code> Component: <code>admin/src/pages/LocationsPage.tsx</code> (1960 lines) Auth Required: Yes (SUPER_ADMIN, MAP_ADMIN roles) Layout: AppLayout</p>"},{"location":"v2/frontend/pages/admin/locations-page/#screenshot","title":"Screenshot","text":"<p>[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.]</p>"},{"location":"v2/frontend/pages/admin/locations-page/#features","title":"Features","text":""},{"location":"v2/frontend/pages/admin/locations-page/#core-features","title":"Core Features","text":"<ul> <li>Dual-tab interface \u2014 Table view (CRUD operations) + Map view (visual management)</li> <li>Full CRUD operations \u2014 Create, read, update, delete locations</li> <li>Advanced search \u2014 300ms debounced search by address or postal code</li> <li>Confidence filtering \u2014 Filter by High (\u226585%), Medium (60-84%), Low (<60%), Manual/None</li> <li>Building type tracking \u2014 SINGLE_FAMILY, MULTI_UNIT, MIXED_USE, COMMERCIAL</li> <li>Multi-unit support \u2014 Expandable rows show Address units (apartments) with contact info</li> <li>Location history \u2014 Track all changes (created, updated, moved, geocoded) with user attribution</li> <li>Bulk operations \u2014 Select multiple rows, bulk delete with confirmation</li> </ul>"},{"location":"v2/frontend/pages/admin/locations-page/#geocoding-features","title":"Geocoding Features","text":"<ul> <li>Multi-provider geocoding \u2014 6 providers (Google, Nominatim, ArcGIS, Photon, Mapbox, Pelias)</li> <li>Inline geocoding \u2014 \"Geocode\" button in create/edit forms</li> <li>Geocode Missing \u2014 Batch geocode all locations without coordinates</li> <li>Bulk Re-Geocode \u2014 Background job to improve low-confidence locations</li> <li>Confidence scoring \u2014 0-100% confidence with provider attribution</li> <li>Reverse geocoding \u2014 Click map \u2192 auto-fill address from coordinates</li> </ul>"},{"location":"v2/frontend/pages/admin/locations-page/#import-features","title":"Import Features","text":"<ul> <li>Standard CSV import \u2014 Simple address/contact CSV with flexible column mapping</li> <li>NAR Upload import \u2014 Statistics Canada NAR format (2025 + legacy) with client-side upload (max 100MB)</li> <li>NAR Server import \u2014 Server-side streaming import for multi-GB NAR datasets</li> <li>Geographic filters \u2014 Import by cut boundary, city name, postal prefix, province, or map area</li> <li>Residential filtering \u2014 Skip non-residential addresses (commercial, industrial)</li> <li>Deduplication \u2014 5m radius deduplication to prevent duplicate imports</li> <li>Real-time progress \u2014 Live progress bars + stats during NAR server imports</li> </ul>"},{"location":"v2/frontend/pages/admin/locations-page/#map-features-map-tab","title":"Map Features (Map Tab)","text":"<ul> <li>Interactive Leaflet map \u2014 Click-to-add, drag-to-move, GPS locate, fullscreen</li> <li>Color-coded markers \u2014 Visual building type distinction</li> <li>Cut overlays \u2014 Polygon boundaries with toggle controls</li> <li>Auto-refresh \u2014 Viewport-based loading (800ms debounce)</li> <li>Bounds filtering \u2014 Only load locations in current view (max 5000 per request)</li> <li>Click-to-add mode \u2014 Click map \u2192 reverse geocode \u2192 create form</li> <li>Drag-to-move mode \u2014 Drag marker \u2192 update coordinates</li> </ul>"},{"location":"v2/frontend/pages/admin/locations-page/#statistics-dashboard","title":"Statistics Dashboard","text":"<ul> <li>Building type breakdown \u2014 5 cards (Total, Single Family, Multi-Unit, Mixed Use, Commercial)</li> <li>Geocode coverage \u2014 Percentage geocoded + average confidence</li> <li>Confidence distribution \u2014 4 cards (High, Medium, Low, Manual/None) with counts + icons</li> </ul>"},{"location":"v2/frontend/pages/admin/locations-page/#user-workflow","title":"User Workflow","text":""},{"location":"v2/frontend/pages/admin/locations-page/#viewing-locations-table-tab","title":"Viewing Locations (Table Tab)","text":"<ol> <li>Navigate to <code>/app/map/locations</code></li> <li>Page loads with statistics cards at top:</li> <li>First row: Total count, building type breakdowns, geocode percentage</li> <li>Second row: Confidence distribution (high/medium/low/none), average confidence</li> <li>Table tab active by default (20 locations per page)</li> <li>View location details:</li> <li>Address (bold)</li> <li>Building Type tag (color-coded)</li> <li>Total Units (1 for single-family, 2+ for multi-unit)</li> <li>Coordinates (lat, lng to 5 decimals)</li> <li>Geocode confidence (tag + provider name)</li> <li>Created date</li> <li>Actions (edit, delete)</li> <li>Expand row (click anywhere) if Total Units > 1:</li> <li>Shows Address units table (apartments)</li> <li>Columns: Unit, Name, Contact, Building Type, Notes</li> <li>Use pagination at bottom (10/20/50/100 per page)</li> </ol>"},{"location":"v2/frontend/pages/admin/locations-page/#searching-and-filtering","title":"Searching and Filtering","text":"<ol> <li>Search bar (top left):</li> <li>Type address or postal code</li> <li>300ms debounce (waits for typing pause)</li> <li>Search resets pagination to page 1</li> <li>Confidence filter (top right):</li> <li>Select High, Medium, Low, or Manual/None</li> <li>Filter resets pagination to page 1</li> <li>Clear to show all locations</li> <li>Filters persist during pagination</li> </ol>"},{"location":"v2/frontend/pages/admin/locations-page/#creating-a-location-manually","title":"Creating a Location Manually","text":"<ol> <li>Click \"Add Location\" button in page header</li> <li>Modal opens (600px width) with vertical form</li> <li>Fill required fields:</li> <li>Street Address (base building address, no unit number)<ul> <li>Example: \"123 Main St, City, Province\"</li> <li>Click \"Geocode\" button (in input addonAfter) to auto-fill coordinates</li> </ul> </li> <li>Latitude (decimal degrees, 5 decimals, e.g. 45.42153)</li> <li>Longitude (decimal degrees, 5 decimals, e.g. -75.69719)</li> <li>Select Building Type (radio buttons, default: SINGLE_FAMILY):</li> <li>Single Family</li> <li>Multi-Unit</li> <li>Mixed Use</li> <li>Commercial</li> <li>Add Building Notes (optional):</li> <li>Access codes, manager contact, buzzer instructions</li> <li>Example: \"Access code: 1234, Ring buzzer for manager\"</li> <li>Click \"Create\" button</li> <li>Success message: \"Location created\"</li> <li>Modal closes, table refreshes to page 1, stats refresh</li> <li>If Map tab open, new marker appears</li> </ol>"},{"location":"v2/frontend/pages/admin/locations-page/#using-inline-geocoding","title":"Using Inline Geocoding","text":"<ol> <li>In create/edit form, type address in Street Address field</li> <li>Click \"Geocode\" button (AimOutlined icon in input addonAfter)</li> <li>Button shows loading spinner</li> <li>API calls multi-provider geocoding service</li> <li>On success:</li> <li>Latitude and Longitude fields auto-fill</li> <li>Success message: \"Geocoded (Google, 95% confidence)\"</li> <li>Provider name + confidence shown in message</li> <li>On failure:</li> <li>Error message: \"Could not geocode address\"</li> <li>Manually enter coordinates or try different address</li> </ol>"},{"location":"v2/frontend/pages/admin/locations-page/#geocoding-missing-locations","title":"Geocoding Missing Locations","text":"<ol> <li>Click \"Geocode Missing\" button in page header</li> <li>Button shows loading state</li> <li>API geocodes all locations without coordinates (latitude = null OR longitude = null)</li> <li>Success message: \"Geocoded 847 of 1250 locations (403 failed)\"</li> <li>Table refreshes, stats update</li> <li>Failed locations remain without coordinates (low-quality addresses)</li> </ol>"},{"location":"v2/frontend/pages/admin/locations-page/#bulk-re-geocoding-background-job","title":"Bulk Re-Geocoding (Background Job)","text":"<ol> <li>Click \"Bulk Re-Geocode\" button in page header</li> <li>Modal opens (600px width) with form:</li> <li>Confidence Threshold (%): Only geocode below this (default: 60)</li> <li>Building Type Filter: Optionally filter by type</li> <li>Maximum Locations: Process up to N locations (default: 1000, max: 5000)</li> <li>Click \"Start Bulk Re-Geocode\" button</li> <li>Background job starts (BullMQ queue)</li> <li>Modal shows live progress:</li> <li>Progress bar (percentage complete)</li> <li>Current address being processed</li> <li>Stats: Processed X / Total, Improved, Unchanged, Failed</li> <li>Job completes:</li> <li>Final stats shown</li> <li>Success message: \"Bulk geocoding complete: 234 improved, 512 unchanged, 14 failed\"</li> <li>Click \"Close\" button</li> <li>Table refreshes, stats update</li> <li>Only locations with IMPROVED results are updated (unchanged locations left alone)</li> </ol>"},{"location":"v2/frontend/pages/admin/locations-page/#importing-standard-csv","title":"Importing Standard CSV","text":"<ol> <li>Click \"Import CSV\" button in page header</li> <li>Modal opens (620px width) with 3 radio buttons:</li> <li>Standard CSV (selected by default)</li> <li>NAR Upload</li> <li>NAR Server</li> <li>Read format instructions:</li> <li>Columns: address, first name, last name, email, phone, unit number, support level (1-4), sign (yes/no), sign size, notes, latitude, longitude</li> <li>Column names matched flexibly (case-insensitive, ignores punctuation)</li> <li>Drag CSV file or click to upload</li> <li>File uploads, backend processes:</li> <li>Parses CSV rows</li> <li>Creates Location records (with addresses)</li> <li>Creates Address records (units) if unit number present</li> <li>Geocodes if lat/lng missing (optional)</li> <li>Success message: \"Imported 450 of 500 locations (30 warnings, 20 failed)\"</li> <li>If errors, warning modal shows error list (max 300px height, scrollable)</li> <li>Modal closes, table refreshes, stats update</li> </ol>"},{"location":"v2/frontend/pages/admin/locations-page/#importing-nar-upload-client-side","title":"Importing NAR Upload (Client-Side)","text":"<ol> <li>Click \"Import CSV\" button</li> <li>Switch to \"NAR Upload\" radio button</li> <li>Read format instructions:</li> <li>Statistics Canada NAR Address CSV</li> <li>Supports 2025 format (CIVIC_NO, OFFICIAL_STREET_NAME, BG_X/BG_Y) and legacy format (STR_NBR, STR_NME, LAT/LNG)</li> <li>Auto-detects format</li> <li>Configure Geographic Filter dropdown:</li> <li>No filter \u2014 import all rows</li> <li>Map settings area \u2014 use configured center + zoom from Map Settings</li> <li>City name \u2014 enter city (e.g. \"Ottawa\", \"Edmonton\")</li> <li>Province / Territory \u2014 select from dropdown (13 provinces/territories)</li> <li>Cut boundary \u2014 select from cuts dropdown (only locations inside polygon)</li> <li>Toggle \"Residential only\" switch:</li> <li>ON (default): Skip commercial/industrial addresses</li> <li>OFF: Import all addresses</li> <li>Drag CSV file or click to upload (max 100MB)</li> <li>File uploads, backend processes:</li> <li>Parses NAR format (2025 or legacy)</li> <li>Joins Address + Location files if NAR 2025 format</li> <li>Converts BG_X/BG_Y (EPSG:3347 Lambert projection) to lat/lng using proj4</li> <li>Applies geographic filters (cut, city, province, map area)</li> <li>Deduplicates within 5m radius</li> <li>Batches 1000 rows at a time</li> <li>Progress indicator during import</li> <li>Results shown in modal:</li> <li>6 statistics cards (Total Rows, Created, Duplicates, Out of Bounds, Invalid, Errors)</li> <li>Error list (if any)</li> <li>Success message: \"Created X of Y locations\"</li> <li>Table refreshes, stats update</li> </ol>"},{"location":"v2/frontend/pages/admin/locations-page/#importing-nar-server-server-side-streaming","title":"Importing NAR Server (Server-Side Streaming)","text":"<ol> <li>Click \"Import CSV\" button</li> <li>Switch to \"NAR Server\" radio button</li> <li>Click \"Scan Server Directory\" button (first time only)</li> <li>Backend scans NAR_DATA_DIR (<code>./data</code> volume mount) for:</li> <li><code>Addresses/</code> directory with Address_{provinceCode}part.csv files</li> <li><code>Locations/</code> directory with Location_{provinceCode}.csv files</li> <li>Modal shows available provinces:</li> <li>Example: \"ON \u2014 Ontario (6 files, 2.3 GB)\"</li> <li>File count includes multi-part Address files</li> <li>Select province from dropdown</li> <li>Configure Geographic Filter:</li> <li>No filter \u2014 import all addresses</li> <li>City name \u2014 enter city</li> <li>Postal code prefix (FSA) \u2014 3 chars (e.g. K1A, E3B)</li> <li>Cut boundary \u2014 select from cuts dropdown</li> <li>Toggle \"Residential only\" switch (default: ON)</li> <li>Click \"Import {Province} Addresses\" button</li> <li>Backend starts streaming import:<ul> <li>Scans all Address files for province (multi-part files joined)</li> <li>Loads Location file (lat/lng coordinates)</li> <li>Joins Address + Location on LOC_GUID</li> <li>Converts BG_X/BG_Y to lat/lng using proj4</li> <li>Applies filters (city, postal, cut, residential)</li> <li>Deduplicates within 5m radius</li> <li>Batches 1000 rows at a time</li> <li>Streams to DB (never loads full dataset in memory)</li> </ul> </li> <li>Live progress display (polls every 2 seconds):<ul> <li>Progress bar (animated, 99.9% until complete)</li> <li>Status text: \"Processing Address_24_part_3...\"</li> <li>3 statistics cards: Rows Processed, Locations Created, Skipped</li> </ul> </li> <li>Import completes:<ul> <li>Final stats shown (Total Rows, Created, Duplicates, Out of Bounds, Non-Residential, Invalid)</li> <li>Duration shown in seconds</li> <li>Success message: \"Imported 12,847 locations from Ontario in 43.2s\"</li> </ul> </li> <li>Table refreshes, stats update</li> </ol> <p>NAR Server vs Upload: - NAR Server: For multi-GB datasets (1M+ addresses), streams from server disk, no file upload, no size limit - NAR Upload: For smaller datasets (<100MB), client uploads file, faster for small imports</p>"},{"location":"v2/frontend/pages/admin/locations-page/#viewing-map-map-tab","title":"Viewing Map (Map Tab)","text":"<ol> <li>Click \"Map\" tab (EnvironmentOutlined icon)</li> <li>Map loads with AdminMapView component</li> <li>Initial load fetches all locations (no bounds filter)</li> <li>Locations render as colored circle markers:</li> <li>Blue: Single Family</li> <li>Green: Multi-Unit</li> <li>Orange: Mixed Use</li> <li>Purple: Commercial</li> <li>Cut polygons overlay map (if any cuts exist)</li> <li>Floating controls on map:</li> <li>Add \u2014 Enter click-to-add mode</li> <li>Move \u2014 Enter drag-to-move mode</li> <li>GPS \u2014 Geolocate to current position</li> <li>Fullscreen \u2014 Toggle fullscreen mode</li> <li>Refresh \u2014 Reload locations in current view</li> <li>Cut toggles \u2014 Show/hide cut overlays</li> <li>Pan/zoom map \u2192 auto-refreshes after 800ms debounce</li> <li>Click marker \u2192 location detail popup (address, building type, edit button)</li> </ol>"},{"location":"v2/frontend/pages/admin/locations-page/#adding-location-from-map","title":"Adding Location from Map","text":"<ol> <li>In Map tab, click \"Add\" control button</li> <li>Click-to-add mode activated (cursor changes)</li> <li>Click anywhere on map</li> <li>Backend reverse geocodes coordinates (Nominatim)</li> <li>Create modal opens with pre-filled values:</li> <li>Latitude (rounded to 5 decimals)</li> <li>Longitude (rounded to 5 decimals)</li> <li>Address (reverse geocoded, e.g. \"123 Main St, City\")</li> <li>Adjust values if needed</li> <li>Select building type</li> <li>Click \"Create\"</li> <li>New marker appears on map</li> <li>Table updates if viewing Table tab</li> </ol>"},{"location":"v2/frontend/pages/admin/locations-page/#moving-location-on-map","title":"Moving Location on Map","text":"<ol> <li>In Map tab, click \"Move\" control button</li> <li>Drag-to-move mode activated</li> <li>Click and drag any marker to new position</li> <li>On release, coordinates update:</li> <li>PUT <code>/api/map/locations/:id</code> with new lat/lng</li> <li>Marker snaps to new position</li> <li>Success message: \"Location moved\"</li> <li>Table updates if viewing Table tab</li> </ol>"},{"location":"v2/frontend/pages/admin/locations-page/#editing-a-location","title":"Editing a Location","text":"<ol> <li>From Table tab:</li> <li>Click Edit icon button (EditOutlined) in Actions column</li> <li>From Map tab:</li> <li>Click marker \u2192 popup \u2192 click Edit button</li> <li>Drawer opens on right side (700px width) with 2 tabs:</li> <li>Details tab (active by default)</li> <li>History tab (ClockCircleOutlined icon)</li> <li>Details tab shows edit form:</li> <li>Same fields as create form</li> <li>Pre-filled with current values</li> <li>Geocode button available</li> <li>Modify any fields</li> <li>Click \"Save\" button in drawer header</li> <li>Success message: \"Location updated\"</li> <li>Drawer closes, table refreshes, stats update</li> <li>Map refreshes if viewing Map tab</li> </ol>"},{"location":"v2/frontend/pages/admin/locations-page/#viewing-location-history","title":"Viewing Location History","text":"<ol> <li>Open location in edit drawer</li> <li>Click \"History\" tab (ClockCircleOutlined icon)</li> <li>Table loads with location history:</li> <li>Columns: Action, Field, Change, User, When</li> <li>Action tags (color-coded):<ul> <li>CREATED (green)</li> <li>UPDATED (blue)</li> <li>GEOCODED (cyan)</li> <li>MOVED (orange)</li> </ul> </li> <li>Field shows which field changed (e.g. <code>address</code>, <code>latitude</code>)</li> <li>Change shows old \u2192 new values (strikethrough old, bold new)</li> <li>User shows email or \"System\"</li> <li>When shows timestamp (MMM D, YYYY h:mm A)</li> <li>Pagination at bottom (20 per page)</li> <li>History sorted newest first (most recent at top)</li> </ol>"},{"location":"v2/frontend/pages/admin/locations-page/#bulk-deleting-locations","title":"Bulk Deleting Locations","text":"<ol> <li>In Table tab, select checkbox for multiple rows</li> <li>\"Delete Selected (N)\" button appears above table</li> <li>Click button</li> <li>Popconfirm: \"Delete N locations?\"</li> <li>Click \"OK\"</li> <li>Success message: \"Deleted N locations\"</li> <li>Selection cleared, table refreshes, stats update</li> </ol>"},{"location":"v2/frontend/pages/admin/locations-page/#exporting-csv","title":"Exporting CSV","text":"<ol> <li>Click \"Export CSV\" button in page header</li> <li>Browser downloads CSV file: <code>locations-YYYY-MM-DD.csv</code></li> <li>File contains all locations (not just current page):</li> <li>Columns: id, address, latitude, longitude, buildingType, buildingNotes, postalCode, province, federalDistrict, buildingUse, geocodeProvider, geocodeConfidence, totalUnits, createdAt, updatedAt</li> <li>Open in Excel, Google Sheets, or text editor</li> <li>Use for backups, analysis, or importing to other systems</li> </ol>"},{"location":"v2/frontend/pages/admin/locations-page/#component-breakdown","title":"Component Breakdown","text":""},{"location":"v2/frontend/pages/admin/locations-page/#ant-design-components-used","title":"Ant Design Components Used","text":"<ul> <li>Table \u2014 Main locations list with columns, pagination, row selection, expandable rows</li> <li>Tabs \u2014 Dual-tab interface (Table, Map)</li> <li>Input \u2014 Search bar with SearchOutlined prefix</li> <li>Select \u2014 Confidence filter dropdown, province/cut filters in import modal</li> <li>Button \u2014 6 header actions (Settings, Export, Import, Geocode Missing, Bulk Re-Geocode, Add Location) + table actions (Edit, Delete)</li> <li>Card \u2014 Statistics cards (9 cards in 2 rows)</li> <li>Statistic \u2014 Numeric displays with icons, prefixes, suffixes</li> <li>Progress \u2014 Bulk geocode + NAR import progress bars</li> <li>Modal \u2014 Create location, import CSV (3 modes), bulk re-geocode</li> <li>Drawer \u2014 Edit location (700px width, 2 tabs)</li> <li>Form \u2014 Create/edit location forms, bulk geocode config</li> <li>Form.Item \u2014 Field wrappers with labels, rules, help text</li> <li>InputNumber \u2014 Numeric fields (latitude, longitude, max locations)</li> <li>Radio.Group \u2014 Import format selector (3 buttons), building type selector (4 buttons)</li> <li>Switch \u2014 Residential-only toggle in import modals</li> <li>Upload.Dragger \u2014 CSV file upload UI</li> <li>Row, Col \u2014 Responsive grid for stats cards, form fields</li> <li>Tag \u2014 Building type tags, confidence tags, geocode provider, history action tags</li> <li>Space \u2014 Action button grouping</li> <li>Popconfirm \u2014 Delete confirmation (single + bulk)</li> <li>DatePicker \u2014 (Not used, but imported for future features)</li> <li>TimePicker \u2014 (Not used, but imported for future features)</li> <li>Typography.Text \u2014 Labels, descriptions, secondary text</li> </ul>"},{"location":"v2/frontend/pages/admin/locations-page/#table-columns","title":"Table Columns","text":"<pre><code>const columns: ColumnsType<Location> = [\n {\n title: 'Address',\n dataIndex: 'address',\n render: (addr) => <span style={{ fontWeight: 500 }}>{addr || '--'}</span>,\n },\n {\n title: 'Building Type',\n dataIndex: 'buildingType',\n render: (type: BuildingType) => (\n <Tag color={BUILDING_TYPE_COLORS[type]}>\n {BUILDING_TYPE_LABELS[type]}\n </Tag>\n ),\n responsive: ['md'],\n },\n {\n title: 'Total Units',\n dataIndex: 'totalUnits',\n align: 'center',\n width: 120,\n responsive: ['md'],\n },\n {\n title: 'Coordinates',\n render: (_, record) =>\n record.latitude && record.longitude\n ? `${Number(record.latitude).toFixed(5)}, ${Number(record.longitude).toFixed(5)}`\n : '--',\n responsive: ['lg'],\n },\n {\n title: 'Geocode',\n render: (_, record) => {\n if (record.geocodeConfidence != null && record.geocodeConfidence > 0) {\n const confidence = record.geocodeConfidence;\n let color, icon, label;\n if (confidence >= 85) {\n color = 'success';\n icon = <CheckCircleOutlined />;\n label = `High (${confidence}%)`;\n } else if (confidence >= 60) {\n color = 'warning';\n icon = <InfoCircleOutlined />;\n label = `Medium (${confidence}%)`;\n } else {\n color = 'error';\n icon = <WarningOutlined />;\n label = `Low (${confidence}%)`;\n }\n return (\n <Space direction=\"vertical\" size={0}>\n <Tag color={color} icon={icon}>{label}</Tag>\n {record.geocodeProvider && (\n <Text type=\"secondary\" style={{ fontSize: 11 }}>\n {record.geocodeProvider.toLowerCase()}\n </Text>\n )}\n </Space>\n );\n }\n if (record.latitude && record.longitude) {\n return <Tag color=\"blue\">Manual</Tag>;\n }\n return <Tag>None</Tag>;\n },\n responsive: ['lg'],\n },\n {\n title: 'Created',\n dataIndex: 'createdAt',\n render: (date) => dayjs(date).format('YYYY-MM-DD'),\n responsive: ['xl'],\n },\n {\n title: 'Actions',\n width: 120,\n render: (_, record) => (\n <Space>\n <Button type=\"link\" size=\"small\" icon={<EditOutlined />} onClick={() => openEdit(record)} />\n <Popconfirm title=\"Delete this location?\" onConfirm={() => handleDelete(record.id)}>\n <Button type=\"link\" size=\"small\" danger icon={<DeleteOutlined />} />\n </Popconfirm>\n </Space>\n ),\n },\n];\n</code></pre> <p>Key patterns: - <code>responsive</code> array controls column visibility on different screen sizes - <code>render</code> functions for custom content (tags, icons, formatted values) - <code>align: 'center'</code> for numeric columns - <code>width</code> prop for fixed-width columns</p>"},{"location":"v2/frontend/pages/admin/locations-page/#expandable-rows-address-units","title":"Expandable Rows (Address Units)","text":"<pre><code>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</code></pre> <p>Pattern: Nested table shows Address units (apartments) for multi-unit buildings. Only expandable if totalUnits > 1 or addresses array exists.</p>"},{"location":"v2/frontend/pages/admin/locations-page/#statistics-cards","title":"Statistics Cards","text":"<pre><code>{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</code></pre> <p>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)</p>"},{"location":"v2/frontend/pages/admin/locations-page/#nar-format-detection","title":"NAR Format Detection","text":"<p>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)</p> <p>Backend auto-detects format by checking for presence of CIVIC_NO column.</p> <p>2025 format with proj4 conversion:</p> <pre><code>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</code></pre> <p>File join (2025 format only):</p> <pre><code>// 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</code></pre>"},{"location":"v2/frontend/pages/admin/locations-page/#map-auto-refresh","title":"Map Auto-Refresh","text":"<pre><code>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</code></pre> <p>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</p> <p>Safety limit:</p> <pre><code>if (data.length === 5000) {\n message.warning('Too many locations in view. Zoom in for more detail.', 3);\n}\n</code></pre> <p>Backend returns max 5000 locations per request to prevent memory issues.</p>"},{"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":"<p>None \u2014 Locations fetched from API on each interaction. No global state required (unlike canvass or auth).</p>"},{"location":"v2/frontend/pages/admin/locations-page/#local-state","title":"Local State","text":"<pre><code>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</code></pre> <p>Complexity: 40+ state variables for comprehensive feature set.</p>"},{"location":"v2/frontend/pages/admin/locations-page/#polling-patterns","title":"Polling Patterns","text":"<p>NAR Server Import Polling:</p> <pre><code>// 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</code></pre> <p>Bulk Geocode Polling:</p> <p>Similar pattern for bulk geocoding background job. Polls job status every 2 seconds until complete/failed.</p>"},{"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 <code>/api/map/locations</code> List locations (paginated, filtered) GET <code>/api/map/locations/stats</code> Fetch statistics (counts, confidence) GET <code>/api/map/locations/all</code> Fetch all locations (optionally bounds-filtered, max 5000) GET <code>/api/map/locations/:id</code> Fetch single location with addresses GET <code>/api/map/locations/:id/history</code> Fetch location history (paginated) POST <code>/api/map/locations</code> Create location PUT <code>/api/map/locations/:id</code> Update location DELETE <code>/api/map/locations/:id</code> Delete location POST <code>/api/map/locations/bulk-delete</code> Bulk delete locations POST <code>/api/map/locations/geocode</code> Geocode single address POST <code>/api/map/locations/reverse-geocode</code> Reverse geocode coordinates POST <code>/api/map/locations/geocode-missing</code> Batch geocode all missing POST <code>/api/map/locations/bulk-geocode</code> Start bulk re-geocode job GET <code>/api/map/locations/bulk-geocode/:jobId</code> Poll bulk geocode status GET <code>/api/map/locations/export-csv</code> Export all locations as CSV POST <code>/api/map/locations/import-csv</code> Import standard CSV POST <code>/api/map/locations/import-bulk</code> Import NAR CSV (client upload) GET <code>/api/map/nar-import/datasets</code> Scan server NAR directory POST <code>/api/map/nar-import</code> Start NAR server import GET <code>/api/map/nar-import/status/:importId</code> Poll NAR import progress"},{"location":"v2/frontend/pages/admin/locations-page/#list-locations","title":"List Locations","text":"<p>Request:</p> <pre><code>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</code></pre> <p>Response:</p> <pre><code>{\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</code></pre> <p>Key fields: - <code>addresses</code> \u2014 Nested array of Address units (apartments) - <code>totalUnits</code> \u2014 Count of units (1 for single-family, 2+ for multi-unit) - <code>geocodeProvider</code> \u2014 Provider name (GOOGLE, NOMINATIM, etc.) - <code>geocodeConfidence</code> \u2014 0-100% confidence score - <code>postalCode</code>, <code>province</code>, <code>federalDistrict</code> \u2014 NAR import fields</p>"},{"location":"v2/frontend/pages/admin/locations-page/#fetch-statistics","title":"Fetch Statistics","text":"<p>Request:</p> <pre><code>const { data } = await api.get<LocationStats>('/map/locations/stats');\n</code></pre> <p>Response:</p> <pre><code>{\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</code></pre>"},{"location":"v2/frontend/pages/admin/locations-page/#geocode-address","title":"Geocode Address","text":"<p>Request:</p> <pre><code>const { data } = await api.post<GeocodeResult>('/map/locations/geocode', {\n address: '123 Main St, Ottawa, ON',\n});\n</code></pre> <p>Response:</p> <pre><code>{\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</code></pre>"},{"location":"v2/frontend/pages/admin/locations-page/#reverse-geocode","title":"Reverse Geocode","text":"<p>Request:</p> <pre><code>const { data } = await api.post<ReverseGeocodeResult>('/map/locations/reverse-geocode', {\n latitude: 45.42153,\n longitude: -75.69719,\n});\n</code></pre> <p>Response:</p> <pre><code>{\n \"address\": \"123 Main St, Ottawa, ON K1A 0A1, Canada\",\n \"provider\": \"NOMINATIM\"\n}\n</code></pre>"},{"location":"v2/frontend/pages/admin/locations-page/#nar-server-import","title":"NAR Server Import","text":"<p>Request:</p> <pre><code>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</code></pre> <p>Response:</p> <pre><code>{\n \"importId\": \"import-789\"\n}\n</code></pre> <p>Poll status:</p> <pre><code>const { data } = await api.get<NarImportProgress>(`/map/nar-import/status/${importId}`);\n</code></pre> <p>Progress response:</p> <pre><code>{\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</code></pre> <p>Complete response:</p> <pre><code>{\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</code></pre>"},{"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":"<p>Problem: All geocoded locations show Low (<60%) confidence tags.</p> <p>Diagnosis:</p> <p>Check geocoding provider priority in backend: <pre><code>// api/src/modules/map/geocoding/geocoding.service.ts\nconst providers = ['GOOGLE', 'NOMINATIM', 'ARCGIS', 'PHOTON', 'MAPBOX', 'PELIAS'];\n</code></pre></p> <p>Common Issues:</p> <ol> <li>Google API key missing/invalid:</li> <li>Check .env: <code>GOOGLE_GEOCODING_API_KEY=your-key-here</code></li> <li>Verify key has Geocoding API enabled</li> <li> <p>Check quota limits</p> </li> <li> <p>Poor address quality:</p> </li> <li>Addresses missing street number, city, or postal code</li> <li>Example: \"Main St\" (missing number + city) \u2192 low confidence</li> <li> <p>Solution: Clean address data before import</p> </li> <li> <p>Provider fallback chain:</p> </li> <li>Google fails \u2192 tries Nominatim (lower confidence)</li> <li>Nominatim fails \u2192 tries ArcGIS, etc.</li> <li>Solution: Fix primary provider (Google)</li> </ol> <p>Solution:</p> <p>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</p>"},{"location":"v2/frontend/pages/admin/locations-page/#nar-server-import-not-finding-files","title":"NAR Server Import Not Finding Files","text":"<p>Problem: Click \"Scan Server Directory\" \u2192 \"No NAR datasets found in /data\"</p> <p>Diagnosis:</p> <p>Check docker-compose volume mount: <pre><code>volumes:\n - ./data:/data:ro\n</code></pre></p> <p>Common Issues:</p> <ol> <li> <p>Data directory doesn't exist: <pre><code>mkdir -p ./data\n</code></pre></p> </li> <li> <p>NAR files not extracted:</p> </li> <li>Download NAR zip from Statistics Canada</li> <li>Extract to <code>./data/</code> directory</li> <li> <p>Ensure <code>Addresses/</code> and <code>Locations/</code> subdirectories exist</p> </li> <li> <p>Wrong directory structure: <pre><code># 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</code></pre></p> </li> <li> <p>File permissions: <pre><code>chmod -R 755 ./data\n</code></pre></p> </li> </ol> <p>Solution:</p> <pre><code>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</code></pre>"},{"location":"v2/frontend/pages/admin/locations-page/#map-shows-too-many-locations-in-view","title":"Map Shows \"Too Many Locations in View\"","text":"<p>Problem: Zoom out on map \u2192 Warning: \"Too many locations in view. Zoom in for more detail.\"</p> <p>Diagnosis:</p> <p>Backend safety limit triggered: <pre><code>// Max 5000 locations per request\nif (locations.length >= 5000) {\n return res.json(locations.slice(0, 5000));\n}\n</code></pre></p> <p>Not an error: Protection against loading millions of markers.</p> <p>Solution:</p> <ol> <li>Zoom in to reduce visible area</li> <li>Map auto-refreshes with smaller bounds</li> <li>Fewer locations load (no warning)</li> </ol> <p>Alternative: Use Table tab + search/filters to find specific locations.</p>"},{"location":"v2/frontend/pages/admin/locations-page/#bulk-re-geocode-stuck-at-99","title":"Bulk Re-Geocode Stuck at 99%","text":"<p>Problem: Start bulk re-geocode \u2192 progress bar reaches 99% \u2192 never completes.</p> <p>Diagnosis:</p> <p>Check BullMQ queue health: <pre><code>docker compose logs -f api\n# Look for: \"Bulk geocode job failed: ETIMEDOUT\"\n</code></pre></p> <p>Common Issues:</p> <ol> <li>Geocoding provider timeout:</li> <li>Google API rate limit exceeded (50 req/sec)</li> <li> <p>Solution: Reduce job concurrency in backend</p> </li> <li> <p>Redis connection lost:</p> </li> <li>Check redis container: <code>docker compose ps redis</code></li> <li> <p>Solution: Restart redis: <code>docker compose restart redis</code></p> </li> <li> <p>Job worker crashed:</p> </li> <li>Check API logs for errors</li> <li>Solution: Restart API: <code>docker compose restart api</code></li> </ol> <p>Solution:</p> <p>Cancel stuck job: 1. Close bulk geocode modal 2. Restart API container: <code>docker compose restart api</code> 3. Retry bulk geocode with smaller limit (e.g., 500 instead of 1000)</p>"},{"location":"v2/frontend/pages/admin/locations-page/#csv-import-shows-invalid-errors","title":"CSV Import Shows \"Invalid\" Errors","text":"<p>Problem: Import CSV \u2192 Result: \"450 created, 50 invalid\"</p> <p>Diagnosis:</p> <p>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)\"</p> <p>Common Issues:</p> <ol> <li>Missing required columns:</li> <li>Standard CSV: address required, lat/lng optional</li> <li> <p>NAR CSV: Address file requires CIVIC_NO + OFFICIAL_STREET_NAME (2025) or STR_NBR + STR_NME (legacy)</p> </li> <li> <p>Invalid coordinates:</p> </li> <li>Latitude out of range (-90 to 90)</li> <li>Longitude out of range (-180 to 180)</li> <li> <p>Non-numeric values in lat/lng columns</p> </li> <li> <p>Encoding issues:</p> </li> <li>CSV not UTF-8 encoded</li> <li>Solution: Re-save CSV as UTF-8 in Excel/LibreOffice</li> </ol> <p>Solution:</p> <ol> <li>Export failed rows to new CSV for fixing</li> <li>Clean data in spreadsheet:</li> <li>Fill missing addresses</li> <li>Fix coordinate ranges</li> <li>Remove invalid characters</li> <li>Re-import cleaned CSV</li> </ol>"},{"location":"v2/frontend/pages/admin/locations-page/#related-documentation","title":"Related Documentation","text":"<ul> <li>Locations Module (Backend) \u2014 API implementation, schemas, geocoding service</li> <li>Geocoding Service \u2014 Multi-provider geocoding, confidence scoring</li> <li>NAR Import Service \u2014 NAR format parsing, streaming import</li> <li>Cuts Module \u2014 Polygon boundaries, spatial queries</li> <li>AdminMapView Component \u2014 Interactive map with controls</li> <li>Public Map Page \u2014 Public-facing map view</li> <li>Map Feature Guide \u2014 End-to-end location management workflow</li> <li>NAR Import Guide \u2014 Step-by-step NAR import instructions</li> <li>Locations API Reference \u2014 Complete endpoint documentation</li> <li>Troubleshooting: Geocoding Issues \u2014 Geocoding debugging</li> </ul>"},{"location":"v2/frontend/pages/admin/mailhog-page/","title":"MailHogPage","text":""},{"location":"v2/frontend/pages/admin/mailhog-page/#overview","title":"Overview","text":"<p>File: <code>admin/src/pages/MailHogPage.tsx</code></p> <p>Route: <code>/app/services/mailhog</code></p> <p>Role Requirements: Any authenticated user (uses <code>authenticate</code> middleware)</p> <p>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.</p> <p>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</p> <p>Layout: AppLayout with fullbleed (no content padding)</p> <p>Dependencies: - Ant Design v5 (Button, Space, Badge, Spin, Grid, Result) - react-router-dom (useOutletContext)</p>"},{"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":"<p>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</p> <p>Status Checks: - Initial check on page load - Manual check via \"Refresh\" button - No automatic periodic refresh</p>"},{"location":"v2/frontend/pages/admin/mailhog-page/#2-mobile-device-detection","title":"2. Mobile Device Detection","text":"<p>Mobile Warning Screen: - Detects mobile devices using <code>Grid.useBreakpoint()</code> - Shows warning Result component on mobile - Recommends using desktop for better email viewing experience - Icon: MailOutlined (48px)</p> <p>Breakpoint: <code>!screens.md</code> (screen width < 768px = mobile)</p>"},{"location":"v2/frontend/pages/admin/mailhog-page/#3-service-url-building","title":"3. Service URL Building","text":"<p>URL Construction: - Fetches service config from API (<code>/api/services/config</code>) - Builds URL using <code>buildServiceUrl()</code> helper - Uses subdomain + domain + port configuration - Example: <code>http://mailhog.cmlite.org</code> or <code>http://localhost:8025</code></p>"},{"location":"v2/frontend/pages/admin/mailhog-page/#4-iframe-embedding","title":"4. Iframe Embedding","text":"<p>Fullbleed Layout: - No padding around iframe - Height: <code>calc(100vh - 64px)</code> (full viewport height minus header) - Width: 100% - No border for seamless integration</p> <p>Error Handling: - Shows error Result if service offline - Provides \"Retry\" button to re-check status - Clear error messaging</p>"},{"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":"<ol> <li>Navigate to MailHog:</li> <li>Click \"Services\" \u2192 \"MailHog\" in sidebar</li> <li> <p>Page loads with status check</p> </li> <li> <p>Check Service Status:</p> </li> <li> <p>Status badge appears in page header:</p> <ul> <li>\u2705 \"Online\" (green) - Service available</li> <li>\u274c \"Offline\" (red) - Service unavailable</li> <li>\ud83d\udd35 \"Checking...\" (blue) - Status check in progress</li> </ul> </li> <li> <p>View on Desktop:</p> </li> <li> <p>If on desktop (screen width \u2265 768px):</p> <ul> <li>Iframe loads automatically</li> <li>Full MailHog interface embedded in page</li> <li>Can view captured emails, search, delete</li> </ul> </li> <li> <p>View on Mobile:</p> </li> <li> <p>If on mobile (screen width < 768px):</p> <ul> <li>Warning message appears</li> <li>Message: \"MailHog requires a desktop browser\"</li> <li>\"Open in New Tab\" button provided</li> <li>Click button to open service in separate browser tab</li> </ul> </li> <li> <p>Using MailHog Service:</p> </li> <li>Inbox View: See all captured emails</li> <li>Email Preview: Click email to view full content (HTML + text)</li> <li>Search: Filter emails by sender, recipient, subject</li> <li>Delete: Delete individual emails or clear all</li> <li> <p>Raw View: View raw email source (headers + body)</p> </li> <li> <p>Troubleshoot Offline Service:</p> </li> <li>If service shows \"Offline\":<ul> <li>Click \"Retry\" button to re-check</li> <li>Check Docker container: <code>docker compose ps mailhog</code></li> <li>Restart service: <code>docker compose restart mailhog</code></li> <li>Verify nginx routing: Check <code>nginx/conf.d/services.conf</code></li> </ul> </li> <li>Refresh page after fixing</li> </ol>"},{"location":"v2/frontend/pages/admin/mailhog-page/#component-breakdown","title":"Component Breakdown","text":""},{"location":"v2/frontend/pages/admin/mailhog-page/#main-component-structure","title":"Main Component Structure","text":"<pre><code>export default function MailHogPage() {\n const { setPageHeader } = useOutletContext<AppOutletContext>();\n const screens = Grid.useBreakpoint();\n const isMobile = !screens.md;\n\n const [online, setOnline] = useState<boolean | null>(null);\n const [config, setConfig] = useState<ServicesConfig | null>(null);\n const [loading, setLoading] = useState(true);\n\n // Fetch service status and config\n const fetchStatus = useCallback(async () => {\n try {\n const [statusRes, configRes] = await Promise.all([\n api.get<ServicesStatus>('/services/status'),\n api.get<ServicesConfig>('/services/config'),\n ]);\n setOnline(statusRes.data.mailhog.online);\n setConfig(configRes.data);\n } catch {\n setOnline(false);\n } finally {\n setLoading(false);\n }\n }, []);\n\n useEffect(() => {\n fetchStatus();\n }, [fetchStatus]);\n\n // Build service URL\n const serviceUrl = config\n ? buildServiceUrl(config.mailhogSubdomain, config.domain, config.mailhogPort)\n : null;\n\n // Page header with status badge and actions\n const headerActions = useMemo(() => (\n <Space>\n <Badge\n status={online === null ? 'processing' : online ? 'success' : 'error'}\n text={online === null ? 'Checking...' : online ? 'Online' : 'Offline'}\n />\n <Button icon={<ReloadOutlined />} onClick={fetchStatus} size=\"small\">\n Refresh\n </Button>\n {serviceUrl && (\n <Button icon={<LinkOutlined />} href={serviceUrl} target=\"_blank\" size=\"small\">\n Open in New Tab\n </Button>\n )}\n </Space>\n ), [online, fetchStatus, serviceUrl]);\n\n useEffect(() => {\n setPageHeader({ title: 'MailHog', actions: headerActions, fullBleed: true });\n return () => setPageHeader(null);\n }, [setPageHeader, headerActions]);\n\n // Mobile warning\n if (isMobile) {\n return (\n <Result\n status=\"info\"\n title=\"Desktop Required\"\n subTitle=\"MailHog requires a desktop browser with a larger screen.\"\n icon={<MailOutlined style={{ fontSize: 48 }} />}\n />\n );\n }\n\n // Loading state\n if (loading) {\n return (\n <div style={{ textAlign: 'center', padding: 80 }}>\n <Spin size=\"large\" />\n </div>\n );\n }\n\n // Offline state\n if (!online || !serviceUrl) {\n return (\n <Result\n status=\"error\"\n title=\"MailHog Unavailable\"\n subTitle=\"MailHog is not running or could not be reached. Check that the MailHog container is started.\"\n extra={\n <Button type=\"primary\" onClick={fetchStatus}>\n Retry\n </Button>\n }\n />\n );\n }\n\n // Iframe embed\n return (\n <iframe\n src={serviceUrl}\n style={{\n width: '100%',\n height: 'calc(100vh - 64px)',\n border: 'none',\n display: 'block',\n }}\n title=\"MailHog\"\n />\n );\n}\n</code></pre>"},{"location":"v2/frontend/pages/admin/mailhog-page/#ant-design-components-used","title":"Ant Design Components Used","text":"<ol> <li>Button - Refresh and \"Open in New Tab\" action buttons</li> <li>Space - Header action button grouping</li> <li>Badge - Service status indicator (success/error/processing)</li> <li>Spin - Loading spinner during status check</li> <li>Grid.useBreakpoint() - Responsive breakpoint detection</li> <li>Result - Mobile warning and offline error screens</li> </ol>"},{"location":"v2/frontend/pages/admin/mailhog-page/#state-management","title":"State Management","text":""},{"location":"v2/frontend/pages/admin/mailhog-page/#local-component-state-usestate","title":"Local Component State (useState)","text":"<pre><code>// 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</code></pre>"},{"location":"v2/frontend/pages/admin/mailhog-page/#state-flow","title":"State Flow","text":"<ol> <li>Component Mounts:</li> <li><code>fetchStatus()</code> called in <code>useEffect</code></li> <li>Parallel API calls:<ul> <li><code>GET /api/services/status</code> - Check MailHog online status</li> <li><code>GET /api/services/config</code> - Fetch service configuration</li> </ul> </li> <li>Sets <code>online</code> to <code>true</code> or <code>false</code></li> <li>Sets <code>config</code> with subdomain/domain/port</li> <li> <p>Sets <code>loading</code> to <code>false</code></p> </li> <li> <p>URL Construction:</p> </li> <li><code>buildServiceUrl()</code> constructs full service URL from config</li> <li>Example: <code>http://mailhog.cmlite.org</code> (production with subdomain)</li> <li> <p>Or: <code>http://localhost:8025</code> (development with port)</p> </li> <li> <p>User Clicks Refresh:</p> </li> <li><code>fetchStatus()</code> called again</li> <li>Re-checks service status</li> <li> <p>Updates <code>online</code> and <code>config</code> states</p> </li> <li> <p>Service Online:</p> </li> <li><code>online</code> is <code>true</code></li> <li>Badge shows \"Online\" (green)</li> <li> <p>Iframe renders with MailHog interface</p> </li> <li> <p>Service Offline:</p> </li> <li><code>online</code> is <code>false</code></li> <li>Badge shows \"Offline\" (red)</li> <li>Error Result displayed with \"Retry\" button</li> </ol>"},{"location":"v2/frontend/pages/admin/mailhog-page/#api-integration","title":"API Integration","text":""},{"location":"v2/frontend/pages/admin/mailhog-page/#endpoints-used","title":"Endpoints Used","text":"<ol> <li>GET /api/services/status - Check all service health (includes MailHog)</li> <li>GET /api/services/config - Fetch service configuration (subdomains, ports)</li> </ol>"},{"location":"v2/frontend/pages/admin/mailhog-page/#example-api-calls","title":"Example API Calls","text":""},{"location":"v2/frontend/pages/admin/mailhog-page/#1-fetch-service-status","title":"1. Fetch Service Status","text":"<pre><code>const statusRes = await api.get<ServicesStatus>('/services/status');\nsetOnline(statusRes.data.mailhog.online);\n</code></pre> <p>Response Format: <pre><code>{\n \"mailhog\": { \"online\": true },\n \"nocodb\": { \"online\": true },\n \"n8n\": { \"online\": true },\n \"grafana\": { \"online\": true },\n \"prometheus\": { \"online\": true }\n}\n</code></pre></p>"},{"location":"v2/frontend/pages/admin/mailhog-page/#2-fetch-service-config","title":"2. Fetch Service Config","text":"<pre><code>const configRes = await api.get<ServicesConfig>('/services/config');\nsetConfig(configRes.data);\n</code></pre> <p>Response Format: <pre><code>{\n \"domain\": \"cmlite.org\",\n \"mailhogSubdomain\": \"mailhog\",\n \"mailhogPort\": 8025,\n \"nocodbSubdomain\": \"db\",\n \"nocodbPort\": 8091,\n \"n8nSubdomain\": \"n8n\",\n \"n8nPort\": 5678\n}\n</code></pre></p>"},{"location":"v2/frontend/pages/admin/mailhog-page/#3-build-service-url","title":"3. Build Service URL","text":"<pre><code>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</code></pre>"},{"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":"<pre><code>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</code></pre>"},{"location":"v2/frontend/pages/admin/mailhog-page/#service-url-builder-utility","title":"Service URL Builder Utility","text":"<pre><code>// 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</code></pre>"},{"location":"v2/frontend/pages/admin/mailhog-page/#conditional-rendering-pattern","title":"Conditional Rendering Pattern","text":"<pre><code>// 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</code></pre>"},{"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":"<p>Status and config fetched in parallel with <code>Promise.all()</code>:</p> <pre><code>const [statusRes, configRes] = await Promise.all([\n api.get<ServicesStatus>('/services/status'),\n api.get<ServicesConfig>('/services/config'),\n]);\n</code></pre> <p>Benefit: Total loading time ~200ms (slowest request) instead of ~400ms (sum of both).</p>"},{"location":"v2/frontend/pages/admin/mailhog-page/#2-usecallback-for-fetchstatus","title":"2. useCallback for fetchStatus","text":"<pre><code>const fetchStatus = useCallback(async () => {\n // ... fetch logic\n}, []);\n</code></pre> <p>Benefit: Function identity stable across re-renders, prevents unnecessary effect triggers.</p>"},{"location":"v2/frontend/pages/admin/mailhog-page/#3-usememo-for-header-actions","title":"3. useMemo for Header Actions","text":"<pre><code>const headerActions = useMemo(() => (\n <Space>\n <Badge />\n <Button onClick={fetchStatus} />\n </Space>\n), [online, fetchStatus, serviceUrl]);\n</code></pre> <p>Benefit: Header actions only recreated when dependencies change, preventing unnecessary re-renders.</p>"},{"location":"v2/frontend/pages/admin/mailhog-page/#4-early-mobile-detection","title":"4. Early Mobile Detection","text":"<pre><code>if (isMobile) {\n return <Result />; // No API calls, no iframe\n}\n</code></pre> <p>Benefit: Avoids unnecessary service checks and iframe loading on mobile devices.</p>"},{"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":"<pre><code>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</code></pre> <p>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</p>"},{"location":"v2/frontend/pages/admin/mailhog-page/#accessibility","title":"Accessibility","text":""},{"location":"v2/frontend/pages/admin/mailhog-page/#keyboard-navigation","title":"Keyboard Navigation","text":"<ol> <li>Tab Key: Cycles through header buttons (Refresh, Open in New Tab)</li> <li>Enter Key: Activates focused button</li> <li>Iframe Focus: Tab enters iframe, navigates MailHog interface</li> </ol>"},{"location":"v2/frontend/pages/admin/mailhog-page/#aria-labels","title":"ARIA Labels","text":"<pre><code><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</code></pre>"},{"location":"v2/frontend/pages/admin/mailhog-page/#color-contrast","title":"Color Contrast","text":"<ul> <li>Success badge (green): <code>#52c41a</code> on white background (contrast ratio 4.5:1)</li> <li>Error badge (red): <code>#ff4d4f</code> on white background (contrast ratio 4.5:1)</li> <li>Button text: White on <code>#1890ff</code> (contrast ratio 4.5:1)</li> </ul>"},{"location":"v2/frontend/pages/admin/mailhog-page/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/frontend/pages/admin/mailhog-page/#problem-service-shows-offline-despite-container-running","title":"Problem: Service Shows \"Offline\" Despite Container Running","text":"<p>Solutions:</p> <ol> <li> <p>Verify Docker container: <pre><code>docker compose ps mailhog\n# Should show \"Up\" status\n</code></pre></p> </li> <li> <p>Check MailHog logs: <pre><code>docker compose logs mailhog\n# Look for errors\n</code></pre></p> </li> <li> <p>Test direct access:</p> </li> <li>Open <code>http://localhost:8025</code> in browser</li> <li> <p>If accessible directly, nginx routing issue</p> </li> <li> <p>Check nginx config:</p> </li> <li>Open <code>nginx/conf.d/services.conf</code></li> <li>Verify MailHog proxy block exists</li> <li> <p>Restart nginx: <code>docker compose restart nginx</code></p> </li> <li> <p>Verify API endpoint:</p> </li> <li>Check DevTools Network tab</li> <li>Look for <code>/api/services/status</code> request</li> <li>Verify <code>mailhog.online: true</code> in response</li> </ol>"},{"location":"v2/frontend/pages/admin/mailhog-page/#problem-iframe-not-loading","title":"Problem: Iframe Not Loading","text":"<p>Solutions:</p> <ol> <li>Check CORS/CSP headers:</li> <li>Open DevTools Console</li> <li>Look for errors like \"Refused to display in a frame\"</li> <li> <p>Check nginx X-Frame-Options headers</p> </li> <li> <p>Verify service URL:</p> </li> <li>Check console log: <code>console.log(serviceUrl)</code></li> <li> <p>Should be valid URL (not null/undefined)</p> </li> <li> <p>Test URL in new tab:</p> </li> <li>Click \"Open in New Tab\" button</li> <li>If opens correctly, iframe issue</li> <li>If doesn't open, service issue</li> </ol>"},{"location":"v2/frontend/pages/admin/mailhog-page/#related-documentation","title":"Related Documentation","text":"<ul> <li>Service Management - Docker service orchestration</li> <li>Email Testing - MailHog workflow</li> <li>Services API - Service status endpoints</li> </ul>"},{"location":"v2/frontend/pages/admin/map-settings-page/","title":"MapSettingsPage","text":""},{"location":"v2/frontend/pages/admin/map-settings-page/#overview","title":"Overview","text":"<p>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.</p> <p>Route: <code>/app/map/settings</code> Component: <code>admin/src/pages/MapSettingsPage.tsx</code> (433 lines) Auth Required: Yes (SUPER_ADMIN or MAP_ADMIN role recommended) Layout: AppLayout Backend Module: <code>api/src/modules/map/settings/</code></p>"},{"location":"v2/frontend/pages/admin/map-settings-page/#screenshot","title":"Screenshot","text":"<p>[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.]</p>"},{"location":"v2/frontend/pages/admin/map-settings-page/#features","title":"Features","text":"<ul> <li>City search autocomplete \u2014 Search for cities/places to auto-fill coordinates (geocoding service integration)</li> <li>Manual coordinate entry \u2014 Precise latitude/longitude control with decimal precision</li> <li>Zoom level slider \u2014 Visual zoom selection (2-19 range)</li> <li>Live walk sheet preview \u2014 Real-time preview updates as settings are edited</li> <li>Custom walk sheet headers \u2014 Configurable title and subtitle</li> <li>Custom walk sheet footer \u2014 Multi-line footer text</li> <li>QR code integration \u2014 Up to 3 QR codes with custom URLs and labels</li> <li>Print-optimized preview \u2014 Walk sheet preview matches actual printed output</li> <li>Browser print support \u2014 Print directly from preview with window.print()</li> <li>Two-column layout \u2014 Side-by-side settings and preview for immediate feedback</li> <li>Form validation \u2014 Required fields (latitude, longitude)</li> <li>Responsive design \u2014 Stacked layout on mobile, side-by-side on desktop</li> </ul>"},{"location":"v2/frontend/pages/admin/map-settings-page/#user-workflow","title":"User Workflow","text":""},{"location":"v2/frontend/pages/admin/map-settings-page/#setting-map-center-via-city-search","title":"Setting Map Center via City Search","text":"<ol> <li>Navigate to <code>/app/map/settings</code></li> <li>Locate \"Map Center & Zoom\" card (top of left column)</li> <li>Click city search autocomplete input field</li> <li>Start typing city name (e.g., \"Ottawa\")</li> <li>After 400ms delay, search results appear in dropdown:</li> <li>Display name: \"Ottawa, Ontario, Canada\"</li> <li>Type tag: \"city\" (gray text, right side)</li> <li>Click desired city from dropdown</li> <li>Coordinates auto-fill:</li> <li>Latitude: 45.4215</li> <li>Longitude: -75.6972</li> <li>Zoom: 12 (default zoom for city-level view)</li> <li>Success message: \"Coordinates auto-filled. Fine-tune below.\"</li> <li>Adjust zoom slider if needed (e.g., 14 for closer view)</li> <li>Click \"Save Settings\" button (top-right header)</li> </ol> <p>Search Features: - Debounced search: 400ms delay prevents API spam during typing - Minimum 2 characters: Search requires at least 2 characters - Limit 5 results: Top 5 most relevant results shown - Result types: city, town, village, suburb, neighbourhood - Display format: \"City, State/Province, Country\" + type tag</p>"},{"location":"v2/frontend/pages/admin/map-settings-page/#setting-map-center-manually","title":"Setting Map Center Manually","text":"<ol> <li>Locate latitude/longitude input fields</li> <li>Enter precise coordinates:</li> <li>Latitude: -90 to 90 (e.g., 45.4215)</li> <li>Longitude: -180 to 180 (e.g., -75.6972)</li> <li>Decimal precision: 4 decimal places = ~10 meter accuracy</li> <li>Adjust zoom slider (2-19 range):</li> <li>2-5: Country/continent level</li> <li>6-9: State/province level</li> <li>10-12: City level (default)</li> <li>13-15: Neighborhood level</li> <li>16-19: Street level</li> <li>Click \"Save Settings\"</li> </ol> <p>Use Cases: - Setting map center to campaign office location - Centering on specific neighborhood - Centering on landmark (e.g., Parliament Hill)</p>"},{"location":"v2/frontend/pages/admin/map-settings-page/#customizing-walk-sheet-header","title":"Customizing Walk Sheet Header","text":"<ol> <li>Scroll to \"Walk Sheet Configuration\" card</li> <li>Modify Title field (e.g., \"Canvassing Walk Sheet\")</li> <li>Modify Subtitle field (e.g., \"District Outreach Campaign 2026\")</li> <li>Observe live preview (right column):</li> <li>Header updates immediately as you type</li> <li>Title appears in large bold font (18pt)</li> <li>Subtitle appears below in smaller font (12pt)</li> <li>Click \"Save Settings\" when satisfied</li> </ol> <p>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)</p>"},{"location":"v2/frontend/pages/admin/map-settings-page/#adding-qr-codes","title":"Adding QR Codes","text":"<ol> <li>Locate \"QR Codes\" section (under footer field)</li> <li>Fill in QR Code 1 URL (e.g., \"https://cmlite.org/campaign-info\")</li> <li>Fill in QR Code 1 Label (e.g., \"Campaign Info\")</li> <li>Observe live preview:</li> <li>QR code appears below header (generated via <code>/api/qr</code> endpoint)</li> <li>Label appears below QR code in small font (9pt)</li> <li>Repeat for QR Code 2 and 3 (optional)</li> <li>Click \"Save Settings\"</li> </ol> <p>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</p> <p>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)</p>"},{"location":"v2/frontend/pages/admin/map-settings-page/#customizing-walk-sheet-footer","title":"Customizing Walk Sheet Footer","text":"<ol> <li>Locate Footer field (multi-line textarea)</li> <li>Enter footer text (e.g., \"Return completed sheets to campaign office. Questions? Call 613-555-0100.\")</li> <li>Observe live preview:</li> <li>Footer appears at bottom of walk sheet</li> <li>Centered text, small font (9pt)</li> <li>Gray text color for subtlety</li> <li>Click \"Save Settings\"</li> </ol> <p>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!\")</p>"},{"location":"v2/frontend/pages/admin/map-settings-page/#printing-walk-sheet","title":"Printing Walk Sheet","text":"<p>Option 1: Print from Preview</p> <ol> <li>Click \"Print\" button in preview card header (top-right)</li> <li>Browser print dialog opens</li> <li>Configure print settings:</li> <li>Paper size: Letter (8.5\" \u00d7 11\")</li> <li>Orientation: Portrait</li> <li>Margins: Default (or custom 0.5\")</li> <li>Scale: 100% (do not scale)</li> <li>Click \"Print\" button in dialog</li> <li>Walk sheet prints exactly as shown in preview</li> </ol> <p>Option 2: Print from Header Button</p> <ol> <li>Click \"Print Walk Sheet\" button (page header, next to Save Settings)</li> <li>Same print dialog appears</li> <li>Same print settings apply</li> </ol> <p>Print Optimization:</p> <p>The page includes print-specific CSS:</p> <pre><code>@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</code></pre> <p>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</p>"},{"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":"<ul> <li>Typography.Text \u2014 Labels, descriptions, helper text</li> <li>Form \u2014 Wrap all settings inputs</li> <li>Form.Item \u2014 Individual field wrappers with labels</li> <li>Input \u2014 Text fields (title, subtitle, QR labels)</li> <li>Input.TextArea \u2014 Multi-line footer field</li> <li>InputNumber \u2014 Numeric latitude/longitude inputs</li> <li>Slider \u2014 Zoom level selection (visual slider with marks)</li> <li>AutoComplete \u2014 City search with dropdown suggestions</li> <li>Card \u2014 Section containers (Map Center, Walk Sheet Config, Preview)</li> <li>Row / Col \u2014 Grid layout for two-column design</li> <li>Button \u2014 Save Settings, Print buttons</li> <li>Space \u2014 Button grouping</li> <li>Spin \u2014 Loading indicators (city search, initial page load)</li> <li>message \u2014 Toast notifications for success/error feedback</li> </ul>"},{"location":"v2/frontend/pages/admin/map-settings-page/#two-column-layout","title":"Two-Column Layout","text":"<pre><code><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</code></pre> <p>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)</p>"},{"location":"v2/frontend/pages/admin/map-settings-page/#city-search-autocomplete","title":"City Search Autocomplete","text":"<pre><code><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</code></pre> <p>City Search Handler:</p> <pre><code>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</code></pre> <p>City Select Handler:</p> <pre><code>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</code></pre>"},{"location":"v2/frontend/pages/admin/map-settings-page/#live-walk-sheet-preview","title":"Live Walk Sheet Preview","text":"<pre><code>// Watch all form values for live preview\nconst watched = Form.useWatch(undefined, form) as Record<string, string | undefined> | undefined;\n\n// QR codes from live form values\nconst qrCodes = [\n { url: watched?.qrCode1Url, label: watched?.qrCode1Label },\n { url: watched?.qrCode2Url, label: watched?.qrCode2Label },\n { url: watched?.qrCode3Url, label: watched?.qrCode3Label },\n].filter((qr) => qr.url); // Only include QR codes with URLs\n\nreturn (\n <div className=\"walk-sheet-print\" style={{ /* print styles */ }}>\n {/* Header */}\n <div style={{ borderBottom: '2px solid #000', paddingBottom: 6 }}>\n <div style={{ fontSize: 18, fontWeight: 700, textAlign: 'center' }}>\n {watched?.walkSheetTitle || 'Walk Sheet'}\n </div>\n {watched?.walkSheetSubtitle && (\n <div style={{ fontSize: 12, textAlign: 'center', marginTop: 2 }}>\n {watched.walkSheetSubtitle}\n </div>\n )}\n </div>\n\n {/* QR Codes */}\n {qrCodes.length > 0 && (\n <div style={{ display: 'flex', justifyContent: 'center', gap: 32 }}>\n {qrCodes.map((qr, i) => (\n <div key={i} style={{ textAlign: 'center' }}>\n <img\n src={`/api/qr?text=${encodeURIComponent(qr.url!)}&size=200`}\n alt={qr.label || `QR Code ${i + 1}`}\n style={{ width: 80, height: 80 }}\n />\n {qr.label && <div style={{ fontSize: 9 }}>{qr.label}</div>}\n </div>\n ))}\n </div>\n )}\n\n {/* Contact entry blocks */}\n {[1, 2].map((blockNum) => (\n <div key={blockNum}>\n <FormRow left=\"First Name\" right=\"Last Name\" />\n <FormRow left=\"Email\" right=\"Phone\" />\n <FormRow left=\"Address\" right=\"Unit Number\" />\n {/* Support Level, Sign Request, Sign Size, Visited Date */}\n </div>\n ))}\n\n {/* Notes section */}\n <div style={{ marginTop: 8 }}>\n <div style={{ fontSize: 9, color: '#666' }}>Notes &amp; Comments</div>\n <div style={{ border: '1px solid #999', minHeight: 80 }} />\n </div>\n\n {/* Footer */}\n {watched?.walkSheetFooter && (\n <div style={{ marginTop: 10, textAlign: 'center', fontSize: 9 }}>\n {watched.walkSheetFooter}\n </div>\n )}\n </div>\n);\n</code></pre> <p>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</p>"},{"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":"<pre><code>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</code></pre> <p>State Variables: - <code>form</code> (Form): Ant Design form instance (all settings inputs) - <code>loading</code> (boolean): Initial page load state - <code>saving</code> (boolean): Save button loading state - <code>citySearch</code> (string): City search input value - <code>cityOptions</code> (array): Autocomplete dropdown options - <code>citySearching</code> (boolean): City search loading indicator - <code>cityTimerRef</code> (ref): Debounce timer for city search - <code>printRef</code> (ref): Reference to walk sheet preview div (for future print enhancements)</p> <p>No Global State:</p> <p>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</p>"},{"location":"v2/frontend/pages/admin/map-settings-page/#form-initialization","title":"Form Initialization","text":"<pre><code>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</code></pre> <p>Default Values:</p> <p>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</p>"},{"location":"v2/frontend/pages/admin/map-settings-page/#debounced-city-search","title":"Debounced City Search","text":"<pre><code>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</code></pre> <p>Why 400ms Debounce?</p> <ul> <li>Balance: Longer than typical 300ms to account for slower typing when entering city names</li> <li>Network efficiency: Geocoding API queries are expensive (external service calls)</li> <li>User experience: Users expect slight delay when searching for places (feels intentional)</li> </ul>"},{"location":"v2/frontend/pages/admin/map-settings-page/#api-integration","title":"API Integration","text":""},{"location":"v2/frontend/pages/admin/map-settings-page/#endpoints-used","title":"Endpoints Used","text":"Method Endpoint Purpose Auth GET <code>/api/map/settings</code> Load current settings Required PUT <code>/api/map/settings</code> Update settings Required GET <code>/api/map/geocoding/search</code> Search for cities/places Required GET <code>/api/qr</code> Generate QR code PNG Public (no auth)"},{"location":"v2/frontend/pages/admin/map-settings-page/#load-map-settings","title":"Load Map Settings","text":"<p>Request:</p> <pre><code>const { data } = await api.get<MapSettings>('/map/settings');\n</code></pre> <p>Response (200 OK):</p> <pre><code>{\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</code></pre> <p>Response Fields: - <code>latitude</code> (string): Default map center latitude (stored as string for precision) - <code>longitude</code> (string): Default map center longitude - <code>zoom</code> (number): Default map zoom level (2-19) - <code>walkSheetTitle</code> (string | null): Walk sheet header title - <code>walkSheetSubtitle</code> (string | null): Walk sheet header subtitle - <code>walkSheetFooter</code> (string | null): Walk sheet footer text - <code>qrCode1Url</code> (string | null): First QR code URL - <code>qrCode1Label</code> (string | null): First QR code label - <code>qrCode2Url</code> (string | null): Second QR code URL - <code>qrCode2Label</code> (string | null): Second QR code label - <code>qrCode3Url</code> (string | null): Third QR code URL - <code>qrCode3Label</code> (string | null): Third QR code label</p> <p>Backend Implementation:</p> <p>Map settings are stored as singleton record (only one row in database):</p> <pre><code>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</code></pre>"},{"location":"v2/frontend/pages/admin/map-settings-page/#update-map-settings","title":"Update Map Settings","text":"<p>Request:</p> <pre><code>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</code></pre> <p>Request Body Schema:</p> <pre><code>{\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</code></pre> <p>Response (200 OK):</p> <pre><code>{\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</code></pre> <p>Backend Implementation (Upsert):</p> <pre><code>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</code></pre> <p>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)</p>"},{"location":"v2/frontend/pages/admin/map-settings-page/#search-for-cities-geocoding","title":"Search for Cities (Geocoding)","text":"<p>Request:</p> <pre><code>const { data } = await api.get<GeocodeSearchResult[]>('/map/geocoding/search', {\n params: {\n q: 'Ottawa',\n limit: 5,\n },\n});\n</code></pre> <p>Query Parameters: - <code>q</code> (string, required): Search query (city name, address, landmark) - <code>limit</code> (number, optional): Maximum results to return (default: 10, max: 20)</p> <p>Response (200 OK):</p> <pre><code>[\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</code></pre> <p>Response Fields: - <code>displayName</code> (string): Human-readable location name (e.g., \"Ottawa, Ontario, Canada\") - <code>latitude</code> (number): Latitude coordinate - <code>longitude</code> (number): Longitude coordinate - <code>type</code> (string): Location type (city, town, village, suburb, neighbourhood, etc.) - <code>importance</code> (number): Relevance score (0.0-1.0, higher = more relevant) - <code>boundingBox</code> (object, optional): Geographic bounds for larger areas</p> <p>Sorting: - Results sorted by <code>importance</code> DESC (most relevant first) - Limited to top N results (default 5 for autocomplete)</p> <p>Backend Implementation:</p> <p>Multi-provider geocoding service (see Geocoding Service documentation):</p> <pre><code>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</code></pre> <p>Providers Used (in order of preference): 1. Nominatim (OpenStreetMap) 2. ArcGIS 3. Photon 4. Mapbox (if API key provided)</p>"},{"location":"v2/frontend/pages/admin/map-settings-page/#generate-qr-code","title":"Generate QR Code","text":"<p>Request:</p> <pre><code>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</code></pre> <p>Query Parameters: - <code>text</code> (string, required): URL or text to encode (URL-encoded) - <code>size</code> (number, optional): QR code size in pixels (default: 200, max: 1000)</p> <p>Response (200 OK):</p> <p>Binary PNG image data (Content-Type: image/png)</p> <p>Example URLs: - Campaign website: <code>/api/qr?text=https%3A%2F%2Fcmlite.org%2Fcampaign-info&size=200</code> - Google Form: <code>/api/qr?text=https%3A%2F%2Fforms.gle%2Fabc123&size=200</code> - Phone number: <code>/api/qr?text=tel%3A%2B16135550100&size=200</code></p> <p>QR Code Generation:</p> <p>Backend uses <code>qrcode</code> npm package:</p> <pre><code>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</code></pre> <p>Error Correction Level: - M (Medium): Can recover from 15% damage - Balanced between size and error tolerance - Suitable for most use cases (printed walk sheets)</p>"},{"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":"<pre><code>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</code></pre> <p>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</p>"},{"location":"v2/frontend/pages/admin/map-settings-page/#live-walk-sheet-preview_1","title":"Live Walk Sheet Preview","text":"<pre><code>// 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</code></pre> <p>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</p>"},{"location":"v2/frontend/pages/admin/map-settings-page/#print-specific-css","title":"Print-Specific CSS","text":"<pre><code><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</code></pre> <p>CSS Explanation:</p> <ul> <li>Hide all: <code>body * { visibility: hidden }</code> hides navigation, buttons, etc.</li> <li>Show walk sheet: <code>.walk-sheet-print * { visibility: visible }</code> shows only preview</li> <li>Fixed positioning: Ensures walk sheet starts at top-left of printed page</li> <li>Exact dimensions: 8.5\" \u00d7 11\" Letter size with 0.4\" padding</li> <li>QR code printing: <code>print-color-adjust: exact</code> ensures QR codes print with high contrast</li> <li>Page size: <code>@page { size: letter }</code> sets printer page size</li> </ul>"},{"location":"v2/frontend/pages/admin/map-settings-page/#form-submission-handler","title":"Form Submission Handler","text":"<pre><code>const handleSave = async (values: Record<string, unknown>) => {\n setSaving(true);\n try {\n await api.put('/map/settings', values);\n message.success('Map settings saved');\n } catch (err: unknown) {\n const msg =\n (err as { response?: { data?: { error?: { message?: string } } } })\n ?.response?.data?.error?.message || 'Failed to save settings';\n message.error(msg);\n } finally {\n setSaving(false);\n }\n};\n</code></pre> <p>Error Handling:</p> <p>Extracts specific error message from API response:</p> <pre><code>// 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</code></pre> <p>Generic Fallback:</p> <p>If error message not in expected format, shows generic message:</p> <pre><code>\"Failed to save settings\"\n</code></pre>"},{"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":"<p>City search queries geocoding service after 400ms delay:</p> <pre><code>cityTimerRef.current = setTimeout(async () => {\n // Query geocoding service\n}, 400);\n</code></pre> <p>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</p> <p>Why 400ms?</p> <ul> <li>Slower typing: City names are longer than typical search queries</li> <li>Expensive queries: Geocoding service queries external APIs (Nominatim, ArcGIS)</li> <li>User expectation: Users expect slight delay when searching for places</li> </ul>"},{"location":"v2/frontend/pages/admin/map-settings-page/#live-preview-performance","title":"Live Preview Performance","text":"<p>Walk sheet preview updates on every form keystroke:</p> <pre><code>const watched = Form.useWatch(undefined, form);\n</code></pre> <p>Performance Impact: - Re-renders: Preview re-renders on every form value change - Expensive renders: QR code images regenerated (browser fetches new PNG from <code>/api/qr</code>) - Trade-off: Immediate feedback vs. performance</p> <p>Mitigation:</p> <p>Consider debouncing QR code updates:</p> <pre><code>const debouncedQrUrls = useDebounce([watched?.qrCode1Url, watched?.qrCode2Url, watched?.qrCode3Url], 500);\n</code></pre> <p>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 <code>/api/qr</code> endpoint</p>"},{"location":"v2/frontend/pages/admin/map-settings-page/#print-css-performance","title":"Print CSS Performance","text":"<p>Print-specific CSS uses <code>visibility: hidden</code> instead of <code>display: none</code>:</p> <pre><code>body * { visibility: hidden !important; }\n.walk-sheet-print * { visibility: visible !important; }\n</code></pre> <p>Why Visibility?</p> <ul> <li>Layout preserved: Hidden elements still occupy space (prevents layout shift)</li> <li>Print performance: Browser doesn't need to recalculate layout for print</li> <li>Consistent output: Print preview matches actual printed page</li> </ul> <p>Comparison:</p> <ul> <li>visibility: hidden \u2014 Element invisible but still occupies space</li> <li>display: none \u2014 Element removed from layout entirely (causes layout shift)</li> </ul>"},{"location":"v2/frontend/pages/admin/map-settings-page/#responsive-design","title":"Responsive Design","text":""},{"location":"v2/frontend/pages/admin/map-settings-page/#two-column-layout_1","title":"Two-Column Layout","text":"<p>Settings form and preview adapt to viewport width:</p> <pre><code><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</code></pre> <p>Responsive Breakpoints: - Mobile (xs, <992px): Stacked layout (form on top, preview below) - Desktop (lg, \u2265992px): Side-by-side layout (form 40%, preview 60%)</p> <p>Why 40/60 Split?</p> <ul> <li>Form needs less space: Most inputs are single-line (latitude, longitude, title)</li> <li>Preview needs more space: Walk sheet is 8.5\" \u00d7 11\" (benefits from larger width)</li> <li>Visual balance: 40/60 ratio feels more balanced than 50/50</li> </ul>"},{"location":"v2/frontend/pages/admin/map-settings-page/#mobile-print-behavior","title":"Mobile Print Behavior","text":"<p>On mobile devices, print preview is less practical:</p> <p>Option 1: Hide preview on mobile</p> <pre><code><Col xs={0} lg={14}> {/* Hidden on mobile (xs={0}) */}\n <Card title=\"Walk Sheet Preview\">{/* ... */}</Card>\n</Col>\n</code></pre> <p>Option 2: Show preview below form</p> <pre><code><Col xs={24} lg={14}> {/* Full width mobile, shown below form */}\n <Card title=\"Walk Sheet Preview\">{/* ... */}</Card>\n</Col>\n</code></pre> <p>Current Implementation: Option 2 (show preview below form on mobile)</p> <p>Rationale:</p> <ul> <li>Mobile users can still see preview (helpful for QR code testing)</li> <li>Print button still works on mobile (triggers native print dialog)</li> <li>Stacked layout is natural on mobile (no horizontal scrolling)</li> </ul>"},{"location":"v2/frontend/pages/admin/map-settings-page/#accessibility","title":"Accessibility","text":""},{"location":"v2/frontend/pages/admin/map-settings-page/#keyboard-navigation","title":"Keyboard Navigation","text":"<p>All interactive elements are keyboard-accessible:</p> <p>City Search Autocomplete: - Tab: Focus on autocomplete input - Type: Enter city name - Down Arrow: Navigate dropdown options - Enter: Select focused option - Escape: Close dropdown</p> <p>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\")</p> <p>Buttons: - Tab: Focus on button - Enter/Space: Activate button (print, save)</p>"},{"location":"v2/frontend/pages/admin/map-settings-page/#screen-reader-support","title":"Screen Reader Support","text":"<p>All elements have proper ARIA labels:</p> <p>City Search: <pre><code><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</code></pre></p> <p>Form Fields: <pre><code><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</code></pre></p> <p>Walk Sheet Preview: <pre><code><Card\n title=\"Walk Sheet Preview\"\n aria-label=\"Live preview of walk sheet with current settings\"\n>\n {/* ... */}\n</Card>\n</code></pre></p>"},{"location":"v2/frontend/pages/admin/map-settings-page/#color-contrast","title":"Color Contrast","text":"<p>All text meets WCAG AA standards:</p> <p>Form Labels: - Label text: <code>rgba(0,0,0,0.85)</code> on white = 13.6:1 contrast (AAA)</p> <p>Helper Text: - Helper text: <code>rgba(0,0,0,0.45)</code> on white = 7.0:1 contrast (AA)</p> <p>Walk Sheet Preview: - Header text: <code>#000</code> on white = 21:1 contrast (AAA) - Field labels: <code>#666</code> on white = 5.7:1 contrast (AA)</p>"},{"location":"v2/frontend/pages/admin/map-settings-page/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/frontend/pages/admin/map-settings-page/#city-search-not-working","title":"City Search Not Working","text":"<p>Problem: Type city name in autocomplete, but no dropdown suggestions appear.</p> <p>Diagnosis:</p> <p>Check browser console for errors:</p> <pre><code>GET /api/map/geocoding/search?q=Ottawa&limit=5 500 Internal Server Error\n</code></pre> <p>Possible Causes:</p> <ol> <li>Geocoding service down:</li> <li>All providers (Nominatim, ArcGIS, Photon) unavailable</li> <li> <p>Network connectivity issue</p> </li> <li> <p>Invalid query:</p> </li> <li>Query too short (< 2 characters)</li> <li> <p>Special characters causing parsing errors</p> </li> <li> <p>Rate limiting:</p> </li> <li>Too many requests to geocoding providers</li> <li>IP temporarily blocked</li> </ol> <p>Solution:</p> <ol> <li>For service issues:</li> <li>Check geocoding service logs: <code>docker compose logs api | grep geocoding</code></li> <li>Test Nominatim directly: <code>curl \"https://nominatim.openstreetmap.org/search?q=Ottawa&format=json\"</code></li> <li> <p>If down, wait 5 minutes and retry</p> </li> <li> <p>For invalid queries:</p> </li> <li>Ensure at least 2 characters entered</li> <li> <p>Try simpler query (e.g., \"Ottawa\" instead of \"Ottawa, ON, Canada\")</p> </li> <li> <p>For rate limiting:</p> </li> <li>Wait 1 hour before retrying</li> <li>Use manual coordinate entry instead</li> <li>Consider adding Mapbox API key (higher rate limits)</li> </ol>"},{"location":"v2/frontend/pages/admin/map-settings-page/#qr-codes-not-showing-in-preview","title":"QR Codes Not Showing in Preview","text":"<p>Problem: Enter QR code URL in form, but QR code doesn't appear in preview.</p> <p>Diagnosis:</p> <p>Check QR code API endpoint:</p> <pre><code>curl \"http://localhost:4000/api/qr?text=https%3A%2F%2Fcmlite.org&size=200\" -o test-qr.png\n</code></pre> <p>Expected: PNG file downloaded</p> <p>Actual: Error response</p> <pre><code>{\n \"error\": \"Invalid size parameter\"\n}\n</code></pre> <p>Possible Causes:</p> <ol> <li>Invalid URL:</li> <li>QR code URL field contains invalid URL</li> <li> <p>Special characters not URL-encoded</p> </li> <li> <p>QR API endpoint down:</p> </li> <li>API container not running</li> <li> <p>QR code generation service crashed</p> </li> <li> <p>Browser caching:</p> </li> <li>Browser cached old QR code PNG</li> <li>Need to clear cache or force refresh</li> </ol> <p>Solution:</p> <ol> <li>For invalid URLs:</li> <li>Ensure URL includes protocol: <code>https://</code> not <code>www.</code></li> <li>Test URL in browser: Click URL to verify it opens correctly</li> <li> <p>Check for special characters: URL-encode if necessary</p> </li> <li> <p>For API issues:</p> </li> <li>Check API logs: <code>docker compose logs api | grep qr</code></li> <li>Restart API: <code>docker compose restart api</code></li> <li> <p>Test endpoint: <code>curl \"http://localhost:4000/api/qr?text=test&size=200\"</code></p> </li> <li> <p>For caching:</p> </li> <li>Hard refresh browser: Ctrl+Shift+R (Windows) or Cmd+Shift+R (Mac)</li> <li>Clear browser cache: Settings \u2192 Clear browsing data</li> <li>Add cache-busting param: <code>&v=${Date.now()}</code> to QR code URL</li> </ol>"},{"location":"v2/frontend/pages/admin/map-settings-page/#walk-sheet-prints-with-navigation","title":"Walk Sheet Prints with Navigation","text":"<p>Problem: Click \"Print\" button, but printed page includes navigation sidebar and header.</p> <p>Diagnosis:</p> <p>Check print preview (Ctrl+P or Cmd+P):</p> <p>Expected: Only walk sheet visible</p> <p>Actual: Full page (navigation, header, footer) visible</p> <p>Possible Causes:</p> <ol> <li>Print CSS not applied:</li> <li>Browser not detecting print media query</li> <li> <p>CSS <code>@media print</code> not working</p> </li> <li> <p>CSS specificity issue:</p> </li> <li>Other stylesheets overriding print CSS</li> <li> <p><code>!important</code> flags not effective</p> </li> <li> <p>Browser print settings:</p> </li> <li>\"Print backgrounds\" option disabled</li> <li>\"Headers and footers\" option enabled</li> </ol> <p>Solution:</p> <ol> <li>For CSS issues (developer fix):</li> <li>Increase specificity: <code>body * { visibility: hidden !important; }</code></li> <li>Use <code>@page { margin: 0; }</code> to remove default margins</li> <li> <p>Test in multiple browsers (Chrome, Firefox, Safari)</p> </li> <li> <p>For browser settings:</p> </li> <li>Enable \"Print backgrounds\" in print dialog</li> <li>Disable \"Headers and footers\" in print dialog</li> <li> <p>Select \"None\" for margins</p> </li> <li> <p>Alternative (if CSS fails):</p> </li> <li>Open walk sheet in new window: <code>window.open('/walk-sheet-preview')</code></li> <li>Print from dedicated preview page (no navigation)</li> <li>Use \"Print to PDF\" and print PDF separately</li> </ol>"},{"location":"v2/frontend/pages/admin/map-settings-page/#coordinates-dont-update-map-center","title":"Coordinates Don't Update Map Center","text":"<p>Problem: Save new latitude/longitude in settings, but public map still shows old center.</p> <p>Diagnosis:</p> <p>Check public map settings fetch:</p> <pre><code>curl \"http://localhost:4000/api/map/settings\"\n</code></pre> <p>Expected: New coordinates returned</p> <pre><code>{\n \"latitude\": \"45.4215\",\n \"longitude\": \"-75.6972\"\n}\n</code></pre> <p>Actual: Old coordinates returned</p> <pre><code>{\n \"latitude\": \"43.6532\",\n \"longitude\": \"-79.3832\"\n}\n</code></pre> <p>Possible Causes:</p> <ol> <li>Settings not saved:</li> <li>Save button clicked but API request failed</li> <li> <p>Error message shown but not noticed</p> </li> <li> <p>Database not updated:</p> </li> <li>Database write failed (permissions issue)</li> <li> <p>Transaction rolled back due to error</p> </li> <li> <p>Public map caching:</p> </li> <li>Public map caching old settings in browser</li> <li>Need to clear cache or force refresh</li> </ol> <p>Solution:</p> <ol> <li>For save issues:</li> <li>Check API logs: <code>docker compose logs api | grep \"settings saved\"</code></li> <li>Retry save: Click \"Save Settings\" again</li> <li> <p>Check for error messages: Look for red toast notification</p> </li> <li> <p>For database issues:</p> </li> <li>Check database: <code>docker compose exec v2-postgres psql -U postgres -d v2 -c \"SELECT * FROM \\\"MapSettings\\\"\"</code></li> <li>Verify coordinates match expected values</li> <li> <p>If mismatch, manually update: <code>UPDATE \"MapSettings\" SET latitude = '45.4215', longitude = '-75.6972'</code></p> </li> <li> <p>For caching:</p> </li> <li>Hard refresh public map: Ctrl+Shift+R (Windows) or Cmd+Shift+R (Mac)</li> <li>Clear browser cache: Settings \u2192 Clear browsing data</li> <li>Check API response: Verify <code>/api/map/settings</code> returns new coordinates</li> </ol>"},{"location":"v2/frontend/pages/admin/map-settings-page/#related-documentation","title":"Related Documentation","text":"<ul> <li>Map Settings Backend Module \u2014 Backend settings service</li> <li>Map Settings API Reference \u2014 Settings endpoints</li> <li>Geocoding Service \u2014 Multi-provider geocoding</li> <li>QR Code Module \u2014 QR code generation service</li> <li>Public Map Page \u2014 Public map using settings</li> <li>Walk Sheet Page \u2014 Printable walk sheet generator</li> <li>LocationsPage \u2014 Location management</li> <li>User Guide: Map Organizer \u2014 Map setup workflow</li> <li>Troubleshooting: Geocoding Issues \u2014 Geocoding troubleshooting</li> </ul>"},{"location":"v2/frontend/pages/admin/mini-qr-page/","title":"MiniQRPage","text":""},{"location":"v2/frontend/pages/admin/mini-qr-page/#overview","title":"Overview","text":"<p>File: <code>admin/src/pages/MiniQRPage.tsx</code></p> <p>Route: <code>/app/services/mini-qr</code></p> <p>Role Requirements: Any authenticated user (uses <code>authenticate</code> middleware)</p> <p>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.</p> <p>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</p> <p>Layout: AppLayout with fullbleed (no content padding)</p> <p>Dependencies: - Ant Design v5 (Alert, Spin, Typography, Result, Button, Grid) - react hooks (useState, useEffect)</p>"},{"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":"<p>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</p> <p>Auto-refresh: - Status checked on page load - No automatic periodic refresh (manual refresh via browser required)</p>"},{"location":"v2/frontend/pages/admin/mini-qr-page/#2-mobile-device-detection","title":"2. Mobile Device Detection","text":"<p>Mobile Warning Screen: - Detects mobile devices using <code>Grid.useBreakpoint()</code> - Shows warning Result component on mobile - Recommends using desktop for better QR code generation experience - Provides link to open service in new tab</p> <p>Breakpoint: <code>!screens.md</code> (screen width < 768px = mobile)</p>"},{"location":"v2/frontend/pages/admin/mini-qr-page/#3-iframe-embedding","title":"3. Iframe Embedding","text":"<p>Fullbleed Layout: - No padding around iframe - 100% width and height - Seamless integration with AppLayout</p> <p>Error Handling: - Shows error message if iframe fails to load - Provides troubleshooting guidance</p>"},{"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":"<ol> <li>Navigate to Mini QR:</li> <li>Click \"Services\" \u2192 \"Mini QR\" in sidebar</li> <li> <p>Page loads with status check</p> </li> <li> <p>Check Service Status:</p> </li> <li>Status indicator appears at top:<ul> <li>\u2705 \"Service Online\" (green) - Service available</li> <li>\u274c \"Service Offline\" (red) - Service unavailable</li> </ul> </li> <li> <p>Loading spinner shown during status check</p> </li> <li> <p>View on Desktop:</p> </li> <li> <p>If on desktop (screen width \u2265 768px):</p> <ul> <li>Iframe loads automatically below status</li> <li>Full Mini QR interface embedded in page</li> <li>Use QR generator as normal</li> </ul> </li> <li> <p>View on Mobile:</p> </li> <li> <p>If on mobile (screen width < 768px):</p> <ul> <li>Warning message appears instead of iframe</li> <li>Message: \"Mini QR is best used on desktop\"</li> <li>\"Open in New Tab\" button provided</li> <li>Click button to open service in separate browser tab</li> </ul> </li> <li> <p>Using Mini QR Service:</p> </li> <li>Enter text or URL to encode</li> <li>Select QR code size/format</li> <li>Generate QR code</li> <li> <p>Download QR code image</p> </li> <li> <p>Troubleshoot Offline Service:</p> </li> <li>If service shows \"Offline\":<ul> <li>Check Docker container: <code>docker compose ps mini-qr</code></li> <li>Restart service: <code>docker compose restart mini-qr</code></li> <li>Verify nginx routing: Check <code>nginx/conf.d/services.conf</code></li> </ul> </li> <li>Refresh page after fixing</li> </ol>"},{"location":"v2/frontend/pages/admin/mini-qr-page/#component-breakdown","title":"Component Breakdown","text":""},{"location":"v2/frontend/pages/admin/mini-qr-page/#main-component-structure","title":"Main Component Structure","text":"<pre><code>const MiniQRPage: React.FC = () => {\n // State\n const [loading, setLoading] = useState(true);\n const [online, setOnline] = useState(false);\n\n // Responsive breakpoints\n const screens = Grid.useBreakpoint();\n const isMobile = !screens.md;\n\n // Check service status on mount\n useEffect(() => {\n checkServiceStatus();\n }, []);\n\n const checkServiceStatus = async () => {\n try {\n setLoading(true);\n const response = await api.get('/api/services/mini-qr/status');\n setOnline(response.data.online);\n } catch (error) {\n console.error('Failed to check Mini QR status:', error);\n setOnline(false);\n } finally {\n setLoading(false);\n }\n };\n\n // Show mobile warning if on mobile device\n if (isMobile) {\n return (\n <Result\n icon={<MobileOutlined />}\n title=\"Desktop Recommended\"\n subTitle=\"Mini QR is best used on desktop for optimal QR code generation experience.\"\n extra={\n <Button\n type=\"primary\"\n href=\"http://qr.cmlite.org\"\n target=\"_blank\"\n >\n Open in New Tab\n </Button>\n }\n />\n );\n }\n\n return (\n <div style={{ height: '100%' }}>\n {/* Status indicator */}\n {loading ? (\n <Spin />\n ) : (\n <Alert\n type={online ? 'success' : 'error'}\n message={online ? 'Service Online' : 'Service Offline'}\n showIcon\n />\n )}\n\n {/* Iframe embed */}\n {online && !loading && (\n <iframe\n src=\"http://qr.cmlite.org\"\n style={{\n width: '100%',\n height: 'calc(100% - 60px)', // Subtract status bar height\n border: 'none',\n }}\n title=\"Mini QR Code Generator\"\n />\n )}\n </div>\n );\n};\n</code></pre>"},{"location":"v2/frontend/pages/admin/mini-qr-page/#ant-design-components-used","title":"Ant Design Components Used","text":"<ol> <li>Alert - Service status indicator (success/error)</li> <li>Spin - Loading spinner during status check</li> <li>Result - Mobile warning screen with icon and message</li> <li>Button - \"Open in New Tab\" action button</li> <li>Typography.Text - Descriptive text (if needed)</li> <li>Grid.useBreakpoint() - Responsive breakpoint detection</li> </ol>"},{"location":"v2/frontend/pages/admin/mini-qr-page/#iframe-configuration","title":"Iframe Configuration","text":"<pre><code><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</code></pre> <p>Sandbox Attributes: - <code>allow-same-origin</code> - Allows iframe to access cookies/localStorage - <code>allow-scripts</code> - Allows JavaScript execution - <code>allow-forms</code> - Allows form submission</p>"},{"location":"v2/frontend/pages/admin/mini-qr-page/#state-management","title":"State Management","text":""},{"location":"v2/frontend/pages/admin/mini-qr-page/#local-component-state-usestate","title":"Local Component State (useState)","text":"<p>No Zustand stores used - All state managed locally with React hooks.</p> <pre><code>// 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</code></pre>"},{"location":"v2/frontend/pages/admin/mini-qr-page/#state-flow","title":"State Flow","text":"<ol> <li>Component Mounts:</li> <li><code>checkServiceStatus()</code> called in <code>useEffect</code></li> <li>Sets <code>loading</code> to <code>true</code></li> <li>Fetches service status via <code>GET /api/services/mini-qr/status</code></li> <li>Sets <code>online</code> to <code>true</code> or <code>false</code> based on response</li> <li> <p>Sets <code>loading</code> to <code>false</code></p> </li> <li> <p>Service Online:</p> </li> <li><code>online</code> is <code>true</code></li> <li>Alert shows \"Service Online\" (green)</li> <li> <p>Iframe renders with Mini QR service embedded</p> </li> <li> <p>Service Offline:</p> </li> <li><code>online</code> is <code>false</code></li> <li>Alert shows \"Service Offline\" (red)</li> <li> <p>No iframe rendered (blank space below alert)</p> </li> <li> <p>Mobile Device:</p> </li> <li><code>isMobile</code> is <code>true</code></li> <li>Component returns early with warning Result</li> <li>No status check, no iframe</li> </ol>"},{"location":"v2/frontend/pages/admin/mini-qr-page/#api-integration","title":"API Integration","text":""},{"location":"v2/frontend/pages/admin/mini-qr-page/#endpoints-used","title":"Endpoints Used","text":"<ol> <li>GET /api/services/mini-qr/status - Check Mini QR service health</li> </ol>"},{"location":"v2/frontend/pages/admin/mini-qr-page/#api-client","title":"API Client","text":"<pre><code>import { api } from '@/lib/api';\n\n// All requests use authenticated API client with automatic token refresh\n</code></pre>"},{"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":"<pre><code>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</code></pre> <p>Response Format (Online): <pre><code>{\n \"online\": true,\n \"url\": \"http://qr.cmlite.org\",\n \"message\": \"Mini QR service is online\"\n}\n</code></pre></p> <p>Response Format (Offline): <pre><code>{\n \"online\": false,\n \"message\": \"Mini QR service is offline\",\n \"error\": \"Connection refused\"\n}\n</code></pre></p> <p>Error Handling: - Network errors (500, 503): Treated as offline - Timeout errors: Treated as offline - CORS errors: Treated as offline (service not accessible)</p>"},{"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":"<pre><code>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</code></pre>"},{"location":"v2/frontend/pages/admin/mini-qr-page/#mobile-detection-pattern","title":"Mobile Detection Pattern","text":"<pre><code>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</code></pre> <p>Breakpoint Values: - <code>xs</code>: < 576px (extra small) - <code>sm</code>: \u2265 576px (small) - <code>md</code>: \u2265 768px (medium) \u2190 Used for mobile detection - <code>lg</code>: \u2265 992px (large) - <code>xl</code>: \u2265 1200px (extra large) - <code>xxl</code>: \u2265 1600px (extra extra large)</p>"},{"location":"v2/frontend/pages/admin/mini-qr-page/#fullbleed-layout-pattern","title":"Fullbleed Layout Pattern","text":"<pre><code>// 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</code></pre>"},{"location":"v2/frontend/pages/admin/mini-qr-page/#service-status-check-with-error-handling","title":"Service Status Check with Error Handling","text":"<pre><code>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</code></pre>"},{"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":"<pre><code><iframe\n src=\"http://qr.cmlite.org\"\n loading=\"lazy\" // Defers iframe loading until near viewport\n // ... other props\n/>\n</code></pre> <p>Benefit: Saves bandwidth and CPU by not loading iframe until needed. However, since iframe is typically in viewport immediately, this has minimal impact.</p>"},{"location":"v2/frontend/pages/admin/mini-qr-page/#2-early-mobile-detection","title":"2. Early Mobile Detection","text":"<pre><code>// Check mobile before any API calls or rendering\nif (isMobile) {\n return <Result />; // Render warning immediately, no API calls\n}\n</code></pre> <p>Benefit: Avoids unnecessary service status checks on mobile devices, saving API requests and improving page load time.</p>"},{"location":"v2/frontend/pages/admin/mini-qr-page/#3-single-status-check","title":"3. Single Status Check","text":"<pre><code>useEffect(() => {\n checkServiceStatus(); // Only check once on mount\n}, []); // Empty dependency array = run once\n</code></pre> <p>Benefit: Minimizes API requests. Status is checked once and cached. Manual refresh required to re-check (acceptable for service status).</p>"},{"location":"v2/frontend/pages/admin/mini-qr-page/#4-abort-controller-for-timeout","title":"4. Abort Controller for Timeout","text":"<pre><code>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</code></pre> <p>Benefit: Prevents long-hanging requests if service is slow or unresponsive. Improves user experience by failing fast (5s timeout).</p>"},{"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":"<pre><code>const screens = Grid.useBreakpoint();\nconst isMobile = !screens.md; // Mobile if screen width < 768px\n</code></pre> <p>Responsive Behavior: - Desktop (\u2265 768px): Full iframe embed with status bar - Mobile (< 768px): Warning Result with \"Open in New Tab\" button</p>"},{"location":"v2/frontend/pages/admin/mini-qr-page/#mobile-warning-screen","title":"Mobile Warning Screen","text":"<pre><code>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</code></pre> <p>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</p>"},{"location":"v2/frontend/pages/admin/mini-qr-page/#iframe-height-calculation","title":"Iframe Height Calculation","text":"<pre><code><iframe\n style={{\n width: '100%',\n height: 'calc(100% - 80px)', // Full height minus status bar (80px)\n border: 'none',\n }}\n/>\n</code></pre> <p>Responsive Height: - Uses CSS <code>calc()</code> to subtract status bar height from 100% - Ensures iframe fills remaining vertical space - No fixed height, adapts to browser window size</p>"},{"location":"v2/frontend/pages/admin/mini-qr-page/#accessibility","title":"Accessibility","text":""},{"location":"v2/frontend/pages/admin/mini-qr-page/#keyboard-navigation","title":"Keyboard Navigation","text":"<ol> <li>Tab Key:</li> <li>Focuses \"Open in New Tab\" button (mobile view)</li> <li>Enters iframe (desktop view)</li> <li> <p>Navigates through iframe content (if iframe supports tab navigation)</p> </li> <li> <p>Enter Key:</p> </li> <li>Activates \"Open in New Tab\" button</li> <li> <p>Interacts with iframe elements</p> </li> <li> <p>Escape Key:</p> </li> <li>No special behavior (iframe handles internally)</li> </ol>"},{"location":"v2/frontend/pages/admin/mini-qr-page/#aria-labels","title":"ARIA Labels","text":"<pre><code><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</code></pre>"},{"location":"v2/frontend/pages/admin/mini-qr-page/#color-contrast","title":"Color Contrast","text":"<p>All text meets WCAG AA standards: - Success alert background: <code>#f6ffed</code> with text <code>#52c41a</code> (contrast ratio 4.5:1) - Error alert background: <code>#fff2f0</code> with text <code>#ff4d4f</code> (contrast ratio 4.5:1) - Button text: White on <code>#1890ff</code> (contrast ratio 4.5:1)</p>"},{"location":"v2/frontend/pages/admin/mini-qr-page/#screen-reader-support","title":"Screen Reader Support","text":"<ul> <li>Iframe has descriptive <code>title</code> attribute</li> <li>Status alerts have <code>role=\"alert\"</code> for announcements</li> <li>Result component announces title and subtitle</li> <li>Button has descriptive text and aria-label</li> </ul>"},{"location":"v2/frontend/pages/admin/mini-qr-page/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/frontend/pages/admin/mini-qr-page/#problem-service-shows-offline-despite-container-running","title":"Problem: Service Shows \"Offline\" Despite Container Running","text":"<p>Symptoms: - Status bar shows \"Service Offline\" (red) - Mini QR Docker container is running (<code>docker compose ps</code> shows \"Up\") - Iframe does not load</p> <p>Causes: 1. Nginx routing misconfiguration 2. Service listening on wrong port 3. Network connectivity issues 4. CORS policy blocking status check</p> <p>Solutions:</p> <ol> <li> <p>Verify Docker container status: <pre><code>docker compose ps mini-qr\n# Should show \"Up\" status\n\ndocker compose logs mini-qr\n# Check for error messages\n</code></pre></p> </li> <li> <p>Check nginx routing:</p> </li> <li>Open <code>nginx/conf.d/services.conf</code></li> <li>Verify Mini QR proxy block exists: <pre><code>location /qr/ {\n proxy_pass http://mini-qr:8089/;\n proxy_set_header Host $host;\n proxy_set_header X-Real-IP $remote_addr;\n}\n</code></pre></li> <li> <p>Restart nginx: <code>docker compose restart nginx</code></p> </li> <li> <p>Test direct access:</p> </li> <li>Open browser</li> <li>Navigate to <code>http://localhost:8089</code> (direct container port)</li> <li>If accessible directly but not through nginx, routing issue</li> <li> <p>If not accessible directly, service issue</p> </li> <li> <p>Check service health endpoint: <pre><code>curl http://localhost:8089/health\n# Should return 200 OK\n</code></pre></p> </li> <li> <p>Verify API endpoint:</p> </li> <li>Open browser DevTools (F12)</li> <li>Go to Network tab</li> <li>Refresh page</li> <li>Look for <code>GET /api/services/mini-qr/status</code> request</li> <li> <p>Check response:</p> <ul> <li>200 OK with <code>{\"online\": true}</code> - Service should work</li> <li>200 OK with <code>{\"online\": false}</code> - Service health check failed</li> <li>500/503 - API error</li> </ul> </li> <li> <p>Restart services: <pre><code>docker compose restart mini-qr nginx api\n</code></pre></p> </li> </ol>"},{"location":"v2/frontend/pages/admin/mini-qr-page/#problem-iframe-not-loading-even-when-service-is-online","title":"Problem: Iframe Not Loading Even When Service is Online","text":"<p>Symptoms: - Status bar shows \"Service Online\" (green) - Iframe appears as blank white rectangle - No error messages in console</p> <p>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</p> <p>Solutions:</p> <ol> <li>Check browser console for errors:</li> <li>Open DevTools (F12)</li> <li>Go to Console tab</li> <li>Look for errors like:<ul> <li>\"Refused to display in a frame because it set 'X-Frame-Options' to 'deny'\"</li> <li>\"Refused to frame because it violates the following Content Security Policy directive\"</li> </ul> </li> <li> <p>These indicate CORS/CSP blocking</p> </li> <li> <p>Verify X-Frame-Options header:</p> </li> <li>Check nginx config for Mini QR: <pre><code>location /qr/ {\n proxy_pass http://mini-qr:8089/;\n # Remove or comment out X-Frame-Options\n # add_header X-Frame-Options \"DENY\";\n}\n</code></pre></li> <li> <p>Or set to <code>SAMEORIGIN</code> to allow same-domain embedding: <pre><code>add_header X-Frame-Options \"SAMEORIGIN\";\n</code></pre></p> </li> <li> <p>Check iframe sandbox attributes: <pre><code><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</code></pre></p> </li> <li> <p>Test iframe in isolation:</p> </li> <li>Create simple HTML file: <pre><code><!DOCTYPE html>\n<html>\n<body>\n <iframe src=\"http://qr.cmlite.org\" width=\"800\" height=\"600\"></iframe>\n</body>\n</html>\n</code></pre></li> <li>Open in browser</li> <li>If iframe works here but not in React app, React-specific issue</li> <li> <p>If iframe doesn't work here either, service configuration issue</p> </li> <li> <p>Verify service URL:</p> </li> <li>Check iframe <code>src</code> attribute in code</li> <li>Should be <code>http://qr.cmlite.org</code> (nginx proxied)</li> <li>Try direct URL: <code>http://localhost:8089</code> (for testing only)</li> </ol>"},{"location":"v2/frontend/pages/admin/mini-qr-page/#problem-mobile-warning-shows-on-desktop","title":"Problem: Mobile Warning Shows on Desktop","text":"<p>Symptoms: - Viewing page on desktop computer (large screen) - Warning \"Desktop Recommended\" appears instead of iframe - Screen width clearly > 768px</p> <p>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</p> <p>Solutions:</p> <ol> <li>Check browser zoom:</li> <li>Press <code>Ctrl+0</code> (Windows/Linux) or <code>Cmd+0</code> (Mac) to reset zoom to 100%</li> <li> <p>Refresh page</p> </li> <li> <p>Maximize browser window:</p> </li> <li>Click maximize button or press <code>F11</code> for fullscreen</li> <li>Ensure window width > 768px</li> <li> <p>Refresh page</p> </li> <li> <p>Close DevTools or dock to bottom:</p> </li> <li>If DevTools open in side-by-side mode, window width reduced</li> <li>Close DevTools (F12) or dock to bottom</li> <li> <p>Refresh page</p> </li> <li> <p>Check breakpoint detection:</p> </li> <li>Open browser console (F12)</li> <li>Type: <code>window.innerWidth</code></li> <li>If < 768, window too narrow</li> <li> <p>Resize window wider and refresh</p> </li> <li> <p>Clear browser cache:</p> </li> <li>Hard refresh: <code>Ctrl+Shift+R</code> (Windows/Linux) or <code>Cmd+Shift+R</code> (Mac)</li> <li>Or clear browser cache entirely</li> </ol>"},{"location":"v2/frontend/pages/admin/mini-qr-page/#problem-retry-button-does-nothing","title":"Problem: \"Retry\" Button Does Nothing","text":"<p>Symptoms: - Service shows \"Offline\" - Click \"Retry\" button - Nothing happens, still shows \"Offline\"</p> <p>Causes: 1. Service genuinely offline (not a UI bug) 2. Network connectivity issues 3. API endpoint not responding</p> <p>Solutions:</p> <ol> <li>Wait before retrying:</li> <li>Service may need time to start</li> <li>Wait 30-60 seconds</li> <li> <p>Click \"Retry\" again</p> </li> <li> <p>Check Docker containers: <pre><code>docker compose ps\n# Verify mini-qr, nginx, api all show \"Up\"\n\ndocker compose logs mini-qr\n# Check for startup errors\n</code></pre></p> </li> <li> <p>Restart services: <pre><code>docker compose restart mini-qr nginx api\n# Wait 30 seconds for services to fully start\n# Refresh page and click \"Retry\"\n</code></pre></p> </li> <li> <p>Check network connectivity: <pre><code>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</code></pre></p> </li> <li> <p>Hard refresh page:</p> </li> <li><code>Ctrl+Shift+R</code> (Windows/Linux) or <code>Cmd+Shift+R</code> (Mac)</li> <li>Forces fresh status check</li> </ol>"},{"location":"v2/frontend/pages/admin/mini-qr-page/#problem-iframe-content-not-responsive","title":"Problem: Iframe Content Not Responsive","text":"<p>Symptoms: - Iframe loads correctly - Mini QR interface inside iframe is cut off or has horizontal scrollbar - Cannot see full QR generator form</p> <p>Causes: 1. Mini QR service not responsive 2. Iframe width constraints 3. Service has minimum width requirement</p> <p>Solutions:</p> <ol> <li>Check iframe width:</li> <li>Inspect iframe element in DevTools</li> <li>Verify <code>width: 100%</code> applied</li> <li> <p>Verify parent container has sufficient width</p> </li> <li> <p>Remove iframe sandbox (temporarily): <pre><code><iframe\n src=\"http://qr.cmlite.org\"\n // Remove sandbox for testing\n // sandbox=\"allow-same-origin allow-scripts allow-forms\"\n/>\n</code></pre></p> </li> <li>If content becomes responsive without sandbox, sandbox is blocking responsive behavior</li> <li> <p>Add back sandbox with minimal restrictions</p> </li> <li> <p>Use viewport meta tag in service:</p> </li> <li> <p>If Mini QR is custom service, add to its HTML: <pre><code><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n</code></pre></p> </li> <li> <p>Scale iframe content: <pre><code><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</code></pre></p> </li> <li> <p>Open in new tab:</p> </li> <li>If iframe content truly not responsive, use \"Open in New Tab\" approach</li> <li>Remove iframe, show button like mobile view</li> </ol>"},{"location":"v2/frontend/pages/admin/mini-qr-page/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/frontend/pages/admin/mini-qr-page/#backend-documentation","title":"Backend Documentation","text":"<ul> <li>Services Routes - API endpoints for service health checks</li> <li>Nginx Configuration - Reverse proxy setup for Mini QR</li> </ul>"},{"location":"v2/frontend/pages/admin/mini-qr-page/#frontend-documentation","title":"Frontend Documentation","text":"<ul> <li>AppLayout - Fullbleed layout configuration</li> <li>Service Pages Pattern - Common patterns for service iframe pages</li> </ul>"},{"location":"v2/frontend/pages/admin/mini-qr-page/#feature-documentation","title":"Feature Documentation","text":"<ul> <li>QR Code System - Mini QR service integration</li> <li>Service Management - Docker service orchestration</li> </ul>"},{"location":"v2/frontend/pages/admin/mini-qr-page/#api-documentation","title":"API Documentation","text":"<ul> <li>GET /api/services/mini-qr/status - Check Mini QR service health</li> </ul>"},{"location":"v2/frontend/pages/admin/mini-qr-page/#deployment-documentation","title":"Deployment Documentation","text":"<ul> <li>Mini QR Setup - Docker container configuration</li> <li>Service Monitoring - Health check patterns</li> </ul>"},{"location":"v2/frontend/pages/admin/mini-qr-page/#development-documentation","title":"Development Documentation","text":"<ul> <li>Iframe Security - Sandbox attributes and CSP</li> <li>Responsive Design - Mobile detection patterns</li> </ul>"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/","title":"MkDocsSettingsPage","text":""},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#overview","title":"Overview","text":"<p>File: <code>admin/src/pages/MkDocsSettingsPage.tsx</code></p> <p>Route: <code>/app/docs/settings</code></p> <p>Role Requirements: <code>SUPER_ADMIN</code> only</p> <p>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.</p> <p>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</p> <p>Layout: Full AppLayout with sidebar navigation</p> <p>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)</p>"},{"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":"<p>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</p> <p>Validation: - URL fields validated for proper format - Color fields validated for hex format (#RRGGBB) - Required fields enforced (site name, theme)</p>"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#2-navigation-tab-visual-tree-builder","title":"2. Navigation Tab (Visual Tree Builder)","text":"<p>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</p> <p>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</p> <p>Campaign Link Integration: - Dedicated \"Add Campaign Link\" button - Fetches active campaigns via API - Generates campaign page links automatically - Inserts into navigation tree</p>"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#3-yaml-editor-tab-raw-configuration","title":"3. YAML Editor Tab (Raw Configuration)","text":"<p>Monaco Editor: - Full YAML syntax highlighting - 600px height - Auto-formatting on save - Keyboard shortcuts: - <code>Ctrl+S</code> / <code>Cmd+S</code> - Save changes - <code>Ctrl+F</code> - Find - <code>Ctrl+H</code> - Find and replace</p> <p>Custom YAML Parsing: - Handles Python tags (e.g., <code>!relative $config_dir/includes</code>) - Preserves tag structure during parse/stringify - Error handling for invalid YAML</p> <p>Mobile Warning: - Detects mobile devices with <code>Grid.useBreakpoint()</code> - Shows warning Alert if <code>!screens.md</code> - Recommends using desktop for YAML editing</p>"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#4-build-tab-static-site-generation","title":"4. Build Tab (Static Site Generation)","text":"<p>Build Trigger: - Manual build button - Builds MkDocs static site - Shows success/error messages - Displays build timestamp (last successful build)</p> <p>Build Status: - Last build date (formatted with dayjs) - Build in progress indicator - Build error display</p>"},{"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":"<ol> <li>Navigate to MkDocs Settings:</li> <li>Click \"Documentation\" \u2192 \"MkDocs Settings\" in sidebar</li> <li> <p>Page loads with 4 tabs at top</p> </li> <li> <p>Select Settings Tab:</p> </li> <li>First tab is active by default</li> <li> <p>Shows form with ~15 fields</p> </li> <li> <p>Edit Site Information:</p> </li> <li>Update site name: \"Changemaker Lite Documentation\"</li> <li>Update site description</li> <li>Set author name</li> <li> <p>Configure copyright notice</p> </li> <li> <p>Configure Repository Links:</p> </li> <li>Enter GitHub repository URL</li> <li>Set repository name (e.g., \"changemaker-lite\")</li> <li> <p>Configure edit URI pattern (e.g., \"edit/main/docs/\")</p> </li> <li> <p>Customize Theme:</p> </li> <li>Select theme name (e.g., \"material\")</li> <li>Set primary color (hex, e.g., \"#1976d2\")</li> <li>Set accent color (hex, e.g., \"#f50057\")</li> <li> <p>Add theme features as tags:</p> <ul> <li>Type \"navigation.tabs\" and press Enter</li> <li>Type \"toc.integrate\" and press Enter</li> <li>Repeat for other features</li> </ul> </li> <li> <p>Configure Plugins:</p> </li> <li>Add plugins as tags:<ul> <li>\"search\"</li> <li>\"minify\"</li> <li>\"git-revision-date-localized\"</li> </ul> </li> <li> <p>Click X on tags to remove</p> </li> <li> <p>Add Markdown Extensions:</p> </li> <li> <p>Add extensions as tags:</p> <ul> <li>\"pymdownx.highlight\"</li> <li>\"pymdownx.superfences\"</li> <li>\"admonition\"</li> </ul> </li> <li> <p>Save Settings:</p> </li> <li>Click \"Save Changes\" button at bottom</li> <li>Success message appears</li> <li>Settings persisted to database</li> </ol>"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#building-navigation-structure","title":"Building Navigation Structure","text":"<ol> <li>Navigate to Navigation Tab:</li> <li>Click \"Navigation\" tab (second tab)</li> <li> <p>Tree view loads with current navigation</p> </li> <li> <p>Understand Tree Structure:</p> </li> <li>Sections: Items with children (folder icon)<ul> <li>Example: \"Getting Started\" with sub-pages</li> </ul> </li> <li>Pages: Leaf items (file icon)<ul> <li>Example: \"Installation.md\"</li> </ul> </li> <li> <p>Expandable: Click arrow to expand/collapse sections</p> </li> <li> <p>Reorder Items (Drag and Drop):</p> </li> <li>Click and hold on navigation item</li> <li>Drag to new position</li> <li>Drop to reorder</li> <li> <p>Changes saved automatically</p> </li> <li> <p>Add New Section:</p> </li> <li>Click \"Add Section\" button</li> <li>Modal appears</li> <li>Enter section title (e.g., \"API Reference\")</li> <li>Click \"Create\"</li> <li> <p>New section appears in tree</p> </li> <li> <p>Add New Page:</p> </li> <li>Click \"Add Page\" button</li> <li>Modal appears</li> <li>Enter page title (e.g., \"Authentication API\")</li> <li>Enter file path (e.g., \"api/authentication.md\")</li> <li>Click \"Create\"</li> <li> <p>New page appears in tree</p> </li> <li> <p>Edit Navigation Item:</p> </li> <li>Click \"Edit\" icon next to item</li> <li>Modal appears with title field</li> <li>Update title</li> <li> <p>Click \"Save\"</p> </li> <li> <p>Remove Navigation Item:</p> </li> <li>Click \"Delete\" icon next to item</li> <li>Confirmation modal appears</li> <li>Click \"Confirm\" to remove</li> <li> <p>Item removed from navigation (file remains on disk)</p> </li> <li> <p>Handle Orphaned Files:</p> </li> <li>Scroll to \"Orphaned Files\" section at bottom</li> <li>See list of markdown files not in navigation</li> <li>Two options per file:<ul> <li>Drag to tree: Click and drag file to navigation tree</li> <li>Add button: Click \"Add to Navigation\" for default placement</li> </ul> </li> <li> <p>File moves from orphaned list to navigation tree</p> </li> <li> <p>Add Campaign Links:</p> </li> <li>Click \"Add Campaign Link\" button</li> <li>Modal appears with campaign dropdown</li> <li>Select active campaign from list</li> <li>Click \"Add\"</li> <li> <p>Campaign link inserted into navigation with auto-generated path</p> </li> <li> <p>Save Navigation:</p> <ul> <li>Click \"Save Navigation\" button at bottom</li> <li>Navigation structure persisted</li> <li>MkDocs site rebuilt with new structure</li> </ul> </li> </ol>"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#advanced-yaml-editing","title":"Advanced YAML Editing","text":"<ol> <li>Navigate to YAML Editor Tab:</li> <li>Click \"YAML Editor\" tab (third tab)</li> <li> <p>Monaco editor loads with current mkdocs.yml content</p> </li> <li> <p>Check Mobile Warning:</p> </li> <li> <p>If on mobile device, warning Alert shows:</p> <ul> <li>\"YAML Editor is best used on desktop\"</li> <li>\"Consider using Settings or Navigation tabs instead\"</li> </ul> </li> <li> <p>Edit Raw YAML:</p> </li> <li>Click in editor to position cursor</li> <li> <p>Edit YAML directly: <pre><code>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</code></pre></p> </li> <li> <p>Use Keyboard Shortcuts:</p> </li> <li>Ctrl+S (Cmd+S on Mac): Save changes immediately</li> <li>Ctrl+F: Open find dialog</li> <li>Ctrl+H: Open find and replace dialog</li> <li>Ctrl+Z: Undo</li> <li> <p>Ctrl+Y: Redo</p> </li> <li> <p>Handle Python Tags:</p> </li> <li>Editor preserves custom Python tags: <pre><code>nav:\n - Home: !relative $config_dir/index.md\n</code></pre></li> <li> <p>Tags preserved during parse/stringify cycle</p> </li> <li> <p>Save YAML:</p> </li> <li>Click \"Save Changes\" button below editor</li> <li>OR press Ctrl+S keyboard shortcut</li> <li>Success message appears</li> <li> <p>YAML validated and saved to database</p> </li> <li> <p>Handle YAML Errors:</p> </li> <li>If YAML is invalid, error message appears:<ul> <li>\"Invalid YAML syntax\"</li> <li>Shows line number of error</li> </ul> </li> <li>Fix syntax and try saving again</li> </ol>"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#building-static-site","title":"Building Static Site","text":"<ol> <li>Navigate to Build Tab:</li> <li>Click \"Build\" tab (fourth tab)</li> <li> <p>Build status card loads</p> </li> <li> <p>Check Last Build:</p> </li> <li>See \"Last Build\" timestamp</li> <li> <p>Example: \"Built 2 hours ago\"</p> </li> <li> <p>Trigger New Build:</p> </li> <li>Click \"Build Site\" button</li> <li>Button shows loading spinner</li> <li> <p>Build starts in background</p> </li> <li> <p>Monitor Build Progress:</p> </li> <li>\"Building...\" status appears</li> <li> <p>Wait 10-30 seconds for build to complete</p> </li> <li> <p>View Build Result:</p> </li> <li>Success: Green checkmark, \"Build completed successfully\"</li> <li> <p>Error: Red X, error message displayed</p> </li> <li> <p>Access Built Site:</p> </li> <li>Built site served at <code>http://localhost:4001</code> (production)</li> <li>Or <code>http://localhost:4003</code> (dev server)</li> </ol>"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#component-breakdown","title":"Component Breakdown","text":""},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#main-component-structure","title":"Main Component Structure","text":"<pre><code>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</code></pre>"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#ant-design-components-used","title":"Ant Design Components Used","text":"<ol> <li>Tabs - Four-tab interface switcher</li> <li>Form - Settings form with validation</li> <li>Input / Input.TextArea - Text field inputs</li> <li>Switch - Boolean toggles</li> <li>Select - Dropdown selections (used in modals)</li> <li>Tree - Hierarchical navigation tree view</li> <li>Button - Action buttons (Save, Add, Delete, Build)</li> <li>Modal - Dialogs for add/edit operations</li> <li>Card - Content containers for each tab</li> <li>Alert - Warning messages (mobile YAML editor, orphaned files)</li> <li>Typography.Title - Page heading</li> <li>Typography.Text - Descriptive text</li> <li>message - Toast notifications (save success/error)</li> <li>Space - Component spacing</li> <li>Divider - Visual separators</li> <li>Tag - Closable tags for arrays (features, plugins, etc.)</li> </ol>"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#monaco-editor-configuration","title":"Monaco Editor Configuration","text":"<pre><code><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</code></pre>"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#navigation-tree-structure","title":"Navigation Tree Structure","text":"<pre><code>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</code></pre>"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#custom-yaml-parser-python-tag-handling","title":"Custom YAML Parser (Python Tag Handling)","text":"<pre><code>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</code></pre>"},{"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":"<p>No Zustand stores used - All state managed locally with React hooks.</p> <pre><code>// 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</code></pre>"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#state-flow","title":"State Flow","text":"<ol> <li>Component Mounts:</li> <li><code>loadConfig()</code> called in <code>useEffect</code></li> <li>Fetches MkDocs config via <code>GET /api/docs/config</code></li> <li>Sets <code>config</code>, <code>navStructure</code>, <code>orphanedFiles</code>, <code>yamlContent</code></li> <li> <p>Sets <code>form</code> field values</p> </li> <li> <p>User Edits Settings Tab:</p> </li> <li>Form fields update <code>form</code> state (Ant Design managed)</li> <li>Click \"Save Changes\" \u2192 <code>handleSaveSettings()</code></li> <li>Gets values from <code>form.getFieldsValue()</code></li> <li>Sends <code>PUT /api/docs/config</code> with updated config</li> <li> <p>Re-fetches config on success</p> </li> <li> <p>User Edits Navigation Tab:</p> </li> <li>Drag-and-drop updates <code>navStructure</code> state</li> <li>Add/edit/delete modals update <code>navStructure</code></li> <li>Click \"Save Navigation\" \u2192 <code>handleSaveNavigation()</code></li> <li>Sends <code>PUT /api/docs/config</code> with updated nav structure</li> <li> <p>Re-fetches config on success</p> </li> <li> <p>User Edits YAML Tab:</p> </li> <li>Monaco editor updates <code>yamlContent</code> state on change</li> <li>Click \"Save Changes\" or Ctrl+S \u2192 <code>handleSaveYAML()</code></li> <li>Parses YAML to validate</li> <li>Sends <code>PUT /api/docs/config</code> with parsed YAML object</li> <li> <p>Re-fetches config on success</p> </li> <li> <p>User Triggers Build:</p> </li> <li>Click \"Build Site\" \u2192 <code>handleBuild()</code></li> <li>Sets <code>building</code> to <code>true</code></li> <li>Sends <code>POST /api/docs/build</code></li> <li>Sets <code>building</code> to <code>false</code> on completion</li> <li>Updates <code>lastBuild</code> timestamp</li> </ol>"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#api-integration","title":"API Integration","text":""},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#endpoints-used","title":"Endpoints Used","text":"<ol> <li>GET /api/docs/config - Fetch MkDocs configuration</li> <li>PUT /api/docs/config - Update MkDocs configuration</li> <li>POST /api/docs/build - Trigger static site build</li> <li>GET /api/influence/campaigns - Fetch active campaigns (for campaign link insertion)</li> <li>GET /api/docs/orphaned-files - Fetch markdown files not in navigation</li> </ol>"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#api-client","title":"API Client","text":"<pre><code>import { api } from '@/lib/api';\n\n// All requests use authenticated API client with automatic token refresh\n</code></pre>"},{"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":"<pre><code>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</code></pre> <p>Response Format: <pre><code>{\n \"success\": true,\n \"data\": {\n \"site_name\": \"Changemaker Lite Documentation\",\n \"site_url\": \"https://docs.cmlite.org\",\n \"site_description\": \"Comprehensive documentation for Changemaker Lite platform\",\n \"site_author\": \"Changemaker Team\",\n \"copyright\": \"Copyright &copy; 2025 Changemaker\",\n \"repo_url\": \"https://github.com/example/changemaker-lite\",\n \"repo_name\": \"changemaker-lite\",\n \"edit_uri\": \"edit/main/docs/\",\n \"theme\": {\n \"name\": \"material\",\n \"palette\": {\n \"primary\": \"blue\",\n \"accent\": \"pink\"\n },\n \"features\": [\n \"navigation.tabs\",\n \"navigation.sections\",\n \"toc.integrate\"\n ]\n },\n \"plugins\": [\"search\", \"minify\"],\n \"markdown_extensions\": [\"admonition\", \"pymdownx.highlight\"],\n \"extra_css\": [\"stylesheets/extra.css\"],\n \"extra_javascript\": [\"javascripts/extra.js\"],\n \"nav\": [\n {\n \"key\": \"1\",\n \"title\": \"Home\",\n \"file\": \"index.md\"\n },\n {\n \"key\": \"2\",\n \"title\": \"Getting Started\",\n \"children\": [\n {\n \"key\": \"2-1\",\n \"title\": \"Installation\",\n \"file\": \"getting-started/installation.md\"\n }\n ]\n }\n ],\n \"orphanedFiles\": [\"changelog.md\", \"contributing.md\"],\n \"last_build\": \"2025-02-11T10:30:00Z\"\n }\n}\n</code></pre></p>"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#2-save-settings-form-based","title":"2. Save Settings (Form-Based)","text":"<pre><code>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</code></pre> <p>Request Payload: <pre><code>{\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</code></pre></p>"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#3-save-navigation","title":"3. Save Navigation","text":"<pre><code>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</code></pre> <p>Request Payload: <pre><code>{\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</code></pre></p>"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#4-save-yaml","title":"4. Save YAML","text":"<pre><code>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</code></pre> <p>Request Payload: (Parsed YAML object) <pre><code>{\n \"site_name\": \"Changemaker Lite Documentation\",\n \"theme\": {\n \"name\": \"material\"\n },\n \"nav\": [\n {\n \"Home\": \"index.md\"\n }\n ]\n}\n</code></pre></p>"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#5-build-site","title":"5. Build Site","text":"<pre><code>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</code></pre> <p>Response Format: <pre><code>{\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</code></pre></p>"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#6-fetch-active-campaigns-for-link-insertion","title":"6. Fetch Active Campaigns (for link insertion)","text":"<pre><code>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</code></pre> <p>Response Format: <pre><code>{\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</code></pre></p>"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#7-fetch-orphaned-files","title":"7. Fetch Orphaned Files","text":"<pre><code>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</code></pre> <p>Response Format: <pre><code>{\n \"success\": true,\n \"data\": {\n \"files\": [\n \"changelog.md\",\n \"contributing.md\",\n \"troubleshooting/common-issues.md\"\n ]\n }\n}\n</code></pre></p>"},{"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":"<pre><code>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</code></pre>"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#settings-tab-form-rendering","title":"Settings Tab Form Rendering","text":"<pre><code><Form\n form={form}\n layout=\"vertical\"\n onFinish={handleSaveSettings}\n>\n <Form.Item\n label=\"Site Name\"\n name=\"site_name\"\n rules={[{ required: true, message: 'Site name is required' }]}\n >\n <Input placeholder=\"e.g., Changemaker Lite Documentation\" />\n </Form.Item>\n\n <Form.Item\n label=\"Site URL\"\n name=\"site_url\"\n rules={[\n { required: true, message: 'Site URL is required' },\n { type: 'url', message: 'Must be a valid URL' },\n ]}\n >\n <Input.TextArea\n rows={2}\n placeholder=\"e.g., https://docs.cmlite.org\"\n />\n </Form.Item>\n\n <Form.Item\n label=\"Site Description\"\n name=\"site_description\"\n >\n <Input.TextArea\n rows={3}\n placeholder=\"Brief description of your documentation site\"\n />\n </Form.Item>\n\n <Form.Item\n label=\"Site Author\"\n name=\"site_author\"\n >\n <Input placeholder=\"e.g., Changemaker Team\" />\n </Form.Item>\n\n <Form.Item\n label=\"Copyright\"\n name=\"copyright\"\n >\n <Input placeholder=\"e.g., Copyright &copy; 2025 Changemaker\" />\n </Form.Item>\n\n <Divider>Repository Configuration</Divider>\n\n <Form.Item\n label=\"Repository URL\"\n name=\"repo_url\"\n rules={[{ type: 'url', message: 'Must be a valid URL' }]}\n >\n <Input placeholder=\"e.g., https://github.com/username/repo\" />\n </Form.Item>\n\n <Form.Item\n label=\"Repository Name\"\n name=\"repo_name\"\n >\n <Input placeholder=\"e.g., changemaker-lite\" />\n </Form.Item>\n\n <Form.Item\n label=\"Edit URI\"\n name=\"edit_uri\"\n >\n <Input placeholder=\"e.g., edit/main/docs/\" />\n </Form.Item>\n\n <Divider>Theme Configuration</Divider>\n\n <Form.Item\n label=\"Theme Name\"\n name=\"theme_name\"\n rules={[{ required: true, message: 'Theme is required' }]}\n >\n <Input placeholder=\"e.g., material\" />\n </Form.Item>\n\n <Form.Item\n label=\"Primary Color\"\n name=\"theme_primary\"\n rules={[\n { pattern: /^#[0-9A-Fa-f]{6}$/, message: 'Must be hex color (e.g., #1976d2)' },\n ]}\n >\n <Input placeholder=\"e.g., #1976d2\" />\n </Form.Item>\n\n <Form.Item\n label=\"Accent Color\"\n name=\"theme_accent\"\n rules={[\n { pattern: /^#[0-9A-Fa-f]{6}$/, message: 'Must be hex color (e.g., #f50057)' },\n ]}\n >\n <Input placeholder=\"e.g., #f50057\" />\n </Form.Item>\n\n <Form.Item\n label=\"Theme Features\"\n name=\"theme_features\"\n >\n <Select\n mode=\"tags\"\n placeholder=\"Type feature name and press Enter\"\n style={{ width: '100%' }}\n />\n </Form.Item>\n\n <Divider>Plugins & Extensions</Divider>\n\n <Form.Item\n label=\"Plugins\"\n name=\"plugins\"\n >\n <Select\n mode=\"tags\"\n placeholder=\"Type plugin name and press Enter\"\n style={{ width: '100%' }}\n />\n </Form.Item>\n\n <Form.Item\n label=\"Markdown Extensions\"\n name=\"markdown_extensions\"\n >\n <Select\n mode=\"tags\"\n placeholder=\"Type extension name and press Enter\"\n style={{ width: '100%' }}\n />\n </Form.Item>\n\n <Divider>Additional Assets</Divider>\n\n <Form.Item\n label=\"Extra CSS Files\"\n name=\"extra_css\"\n >\n <Select\n mode=\"tags\"\n placeholder=\"Type CSS file path and press Enter\"\n style={{ width: '100%' }}\n />\n </Form.Item>\n\n <Form.Item\n label=\"Extra JavaScript Files\"\n name=\"extra_javascript\"\n >\n <Select\n mode=\"tags\"\n placeholder=\"Type JS file path and press Enter\"\n style={{ width: '100%' }}\n />\n </Form.Item>\n\n <Form.Item>\n <Button\n type=\"primary\"\n htmlType=\"submit\"\n loading={saving}\n size=\"large\"\n >\n Save Changes\n </Button>\n </Form.Item>\n</Form>\n</code></pre>"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#navigation-tab-tree-rendering","title":"Navigation Tab Tree Rendering","text":"<pre><code><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</code></pre>"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#yaml-editor-tab-rendering","title":"YAML Editor Tab Rendering","text":"<pre><code><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</code></pre>"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#build-tab-rendering","title":"Build Tab Rendering","text":"<pre><code><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</code></pre>"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#drag-and-drop-handler","title":"Drag-and-Drop Handler","text":"<pre><code>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</code></pre>"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#add-campaign-link-handler","title":"Add Campaign Link Handler","text":"<pre><code>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</code></pre>"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#yaml-parser-with-python-tag-support","title":"YAML Parser with Python Tag Support","text":"<pre><code>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</code></pre>"},{"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":"<p>This page does not implement search functionality. Navigation filtering could be added with debouncing if needed.</p>"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#2-lazy-tab-loading","title":"2. Lazy Tab Loading","text":"<pre><code>// 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</code></pre> <p>Benefit: Avoids rendering all tab contents simultaneously, especially heavy Monaco editor.</p>"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#3-monaco-editor-lazy-loading","title":"3. Monaco Editor Lazy Loading","text":"<p>Monaco Editor only mounts when YAML Editor tab is active:</p> <pre><code>{activeTab === 'yaml' && (\n <Editor\n height=\"600px\"\n defaultLanguage=\"yaml\"\n value={yamlContent}\n // ... editor config\n />\n)}\n</code></pre> <p>Benefit: Saves ~500KB of JavaScript bundle loading and initialization time until needed.</p>"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#4-usecallback-for-event-handlers","title":"4. useCallback for Event Handlers","text":"<pre><code>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</code></pre> <p>Benefit: Prevents unnecessary re-renders of child components when handler function identity changes.</p>"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#5-tree-data-memoization","title":"5. Tree Data Memoization","text":"<pre><code>const treeData = useMemo(() => {\n return convertNavToTreeData(navStructure);\n}, [navStructure]);\n\nreturn (\n <Tree treeData={treeData} draggable onDrop={handleDrop} />\n);\n</code></pre> <p>Benefit: Avoids recalculating tree data structure on every render.</p>"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#6-form-field-value-caching","title":"6. Form Field Value Caching","text":"<p>Ant Design Form automatically caches field values, but we explicitly set them only once after loading:</p> <pre><code>useEffect(() => {\n if (config) {\n form.setFieldsValue({\n site_name: config.site_name,\n // ... other fields\n });\n }\n}, [config, form]);\n</code></pre> <p>Benefit: Avoids unnecessary form re-renders and field value updates.</p>"},{"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":"<pre><code>import { Grid } from 'antd';\n\nconst screens = Grid.useBreakpoint();\nconst isMobile = !screens.md; // Mobile if screen width < 768px\n</code></pre>"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#mobile-yaml-editor-warning","title":"Mobile YAML Editor Warning","text":"<pre><code>{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</code></pre> <p>Rationale: Monaco Editor provides poor UX on mobile (small screen, no keyboard shortcuts, slow rendering). Warning nudges users toward form-based Settings tab instead.</p>"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#responsive-form-layout","title":"Responsive Form Layout","text":"<pre><code><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</code></pre>"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#responsive-navigation-tree","title":"Responsive Navigation Tree","text":"<p>Tree component automatically adjusts to container width. Horizontal scrolling enabled for deep nesting:</p> <pre><code><Tree\n style={{ overflowX: 'auto' }} // Horizontal scroll for wide trees\n treeData={treeData}\n draggable\n blockNode\n/>\n</code></pre>"},{"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":"<ol> <li>Tab Key:</li> <li> <p>Cycles through all interactive elements (tabs, form fields, buttons, tree nodes)</p> </li> <li> <p>Arrow Keys:</p> </li> <li>Navigate between tabs in Tabs component</li> <li> <p>Navigate tree structure (up/down/left/right)</p> </li> <li> <p>Enter Key:</p> </li> <li>Submit forms</li> <li>Activate buttons</li> <li> <p>Expand/collapse tree nodes</p> </li> <li> <p>Escape Key:</p> </li> <li>Close modals</li> <li> <p>Cancel drag-and-drop operations</p> </li> <li> <p>Monaco Editor Shortcuts:</p> </li> <li><code>Ctrl+S</code> / <code>Cmd+S</code> - Save YAML</li> <li><code>Ctrl+F</code> - Find</li> <li><code>Ctrl+H</code> - Find and replace</li> <li><code>Ctrl+Z</code> - Undo</li> <li><code>Ctrl+Y</code> - Redo</li> </ol>"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#aria-labels","title":"ARIA Labels","text":"<pre><code><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</code></pre>"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#color-contrast","title":"Color Contrast","text":"<p>All text meets WCAG AA standards: - Primary text: <code>rgba(0, 0, 0, 0.85)</code> on white background (contrast ratio 13.6:1) - Secondary text: <code>rgba(0, 0, 0, 0.45)</code> on white background (contrast ratio 7.5:1) - Button text: White on <code>#1890ff</code> (contrast ratio 4.5:1)</p>"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#focus-indicators","title":"Focus Indicators","text":"<p>Ant Design provides default focus outlines for all interactive elements: - Blue outline on focused inputs - Highlight on focused tree nodes - Border on focused buttons</p>"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#screen-reader-support","title":"Screen Reader Support","text":"<ul> <li>Form labels properly associated with inputs via <code><Form.Item label></code></li> <li>Tree nodes have descriptive text</li> <li>Buttons have descriptive text or aria-labels</li> <li>Alerts have role=\"alert\" for screen reader announcements</li> </ul>"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#problem-yaml-fails-to-save-with-invalid-yaml-syntax","title":"Problem: YAML Fails to Save with \"Invalid YAML syntax\"","text":"<p>Symptoms: - Clicking \"Save Changes\" in YAML Editor shows error - Error message: \"Invalid YAML syntax\"</p> <p>Causes: 1. Syntax errors (missing colons, incorrect indentation, unclosed quotes) 2. Invalid characters in YAML 3. Python tags not properly formatted</p> <p>Solutions:</p> <ol> <li> <p>Check for syntax errors: <pre><code># \u274c Bad: Missing colon after key\nsite_name Changemaker Lite\n\n# \u2705 Good: Proper key-value syntax\nsite_name: Changemaker Lite\n</code></pre></p> </li> <li> <p>Verify indentation: <pre><code># \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</code></pre></p> </li> <li> <p>Escape special characters: <pre><code># \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</code></pre></p> </li> <li> <p>Use Monaco Find to locate errors:</p> </li> <li>Press <code>Ctrl+F</code> to open find dialog</li> <li>Search for <code>:</code> to verify all keys have values</li> <li> <p>Search for <code>\"</code> to verify all quotes are closed</p> </li> <li> <p>Copy YAML to external validator:</p> </li> <li>Copy YAML content from editor</li> <li>Paste into online YAML validator (e.g., yamllint.com)</li> <li>Fix reported errors</li> <li>Paste corrected YAML back into editor</li> </ol>"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#problem-navigation-tree-drag-and-drop-not-working","title":"Problem: Navigation Tree Drag-and-Drop Not Working","text":"<p>Symptoms: - Cannot drag navigation items - Items snap back to original position after drop - No visual feedback during drag</p> <p>Causes: 1. Browser compatibility issues 2. JavaScript errors breaking drag handlers 3. Tree data structure corruption</p> <p>Solutions:</p> <ol> <li>Check browser console for errors:</li> <li>Open DevTools (F12)</li> <li>Check Console tab for red errors</li> <li> <p>Look for errors related to \"onDrop\" or \"Tree\"</p> </li> <li> <p>Refresh page:</p> </li> <li>Hard refresh (Ctrl+Shift+R) to clear cached JavaScript</li> <li> <p>Check if drag-and-drop works after refresh</p> </li> <li> <p>Try alternative reordering:</p> </li> <li>Instead of drag-and-drop, use Edit buttons to change order manually</li> <li> <p>Delete item and re-add in desired position</p> </li> <li> <p>Verify tree data structure:</p> </li> <li>Switch to YAML Editor tab</li> <li> <p>Check <code>nav:</code> section for corrupt structure: <pre><code># \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</code></pre></p> </li> <li> <p>Report browser compatibility:</p> </li> <li>Drag-and-drop may not work in older browsers</li> <li>Update browser to latest version</li> <li>Try different browser (Chrome, Firefox, Edge)</li> </ol>"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#problem-orphaned-files-not-appearing","title":"Problem: Orphaned Files Not Appearing","text":"<p>Symptoms: - \"Orphaned Files\" section is empty - Know that markdown files exist but not in navigation - Expect files to show but don't see them</p> <p>Causes: 1. Files actually included in navigation (just deep in tree) 2. API not detecting files correctly 3. Files located outside docs directory</p> <p>Solutions:</p> <ol> <li>Expand all tree nodes:</li> <li>Click all expand arrows in navigation tree</li> <li> <p>Verify file is truly not present in any section</p> </li> <li> <p>Check file location:</p> </li> <li>Orphaned file detection only scans <code>mkdocs/docs/</code> directory</li> <li>Files in other directories won't appear</li> <li> <p>Move files to <code>mkdocs/docs/</code> if needed</p> </li> <li> <p>Verify file has <code>.md</code> extension:</p> </li> <li>Only <code>.md</code> files detected</li> <li> <p>Files with <code>.txt</code>, <code>.html</code>, etc. won't appear</p> </li> <li> <p>Refresh orphaned files:</p> </li> <li>Save navigation (even without changes)</li> <li>API re-scans for orphaned files on save</li> <li> <p>Check if files appear after save</p> </li> <li> <p>Manually add files:</p> </li> <li>If orphaned detection fails, use \"Add Page\" button</li> <li>Enter file path manually</li> <li>File will be added to navigation</li> </ol>"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#problem-build-fails-with-error-message","title":"Problem: Build Fails with Error Message","text":"<p>Symptoms: - Clicking \"Build Site\" shows error - Error message displayed in alert - Build timestamp not updated</p> <p>Causes: 1. Invalid YAML configuration 2. Missing markdown files referenced in navigation 3. MkDocs Docker container not running 4. Theme or plugin not installed</p> <p>Solutions:</p> <ol> <li>Check configuration validity:</li> <li>Switch to Settings tab</li> <li>Verify all required fields filled</li> <li> <p>Check for validation errors (red borders on inputs)</p> </li> <li> <p>Verify file references:</p> </li> <li>Switch to YAML Editor tab</li> <li>Check <code>nav:</code> section for file paths</li> <li> <p>Ensure all referenced files exist: <pre><code>nav:\n - Home: index.md # Must exist at mkdocs/docs/index.md\n - Guide: guide.md # Must exist at mkdocs/docs/guide.md\n</code></pre></p> </li> <li> <p>Check MkDocs container status:</p> </li> <li>Open terminal</li> <li>Run: <code>docker compose ps</code></li> <li>Verify <code>mkdocs</code> container is \"Up\"</li> <li> <p>If not, start container: <code>docker compose up -d mkdocs</code></p> </li> <li> <p>View build logs:</p> </li> <li>Open terminal</li> <li>Run: <code>docker compose logs mkdocs</code></li> <li>Look for error messages in logs</li> <li> <p>Fix issues indicated in logs (e.g., missing plugin, theme error)</p> </li> <li> <p>Verify theme and plugins installed:</p> </li> <li>Themes/plugins must be installed in MkDocs container</li> <li>Check <code>api/mkdocs/requirements.txt</code> for installed packages</li> <li>Add missing packages to requirements.txt</li> <li>Rebuild container: <code>docker compose up -d --build mkdocs</code></li> </ol>"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#problem-settings-changes-not-persisting","title":"Problem: Settings Changes Not Persisting","text":"<p>Symptoms: - Click \"Save Changes\" in Settings tab - Success message appears - Reload page, changes reverted to old values</p> <p>Causes: 1. Database write failure 2. Form validation errors preventing save 3. YAML overriding database values</p> <p>Solutions:</p> <ol> <li>Check browser console:</li> <li>Open DevTools (F12)</li> <li>Check Console tab for errors</li> <li> <p>Look for API errors (400, 500 status codes)</p> </li> <li> <p>Verify form validation:</p> </li> <li>Look for red borders on input fields</li> <li>Red border indicates validation error</li> <li> <p>Fix validation errors before saving:</p> <ul> <li>URL fields must be valid URLs</li> <li>Color fields must be hex format (#RRGGBB)</li> <li>Required fields must be filled</li> </ul> </li> <li> <p>Check API response:</p> </li> <li>Open DevTools Network tab</li> <li>Click \"Save Changes\"</li> <li>Click <code>PUT /api/docs/config</code> request</li> <li> <p>Check Response tab for error details</p> </li> <li> <p>Verify database connection:</p> </li> <li>Open terminal</li> <li>Run: <code>docker compose logs api</code></li> <li>Look for database connection errors</li> <li> <p>If errors, restart API: <code>docker compose restart api</code></p> </li> <li> <p>Check YAML Editor for conflicts:</p> </li> <li>Switch to YAML Editor tab</li> <li>Save YAML (even without changes)</li> <li>YAML save may override database values</li> <li>Re-enter settings and save again</li> </ol>"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#problem-monaco-editor-not-loading","title":"Problem: Monaco Editor Not Loading","text":"<p>Symptoms: - YAML Editor tab shows blank space or loading spinner - No code editor appears - Console errors related to Monaco</p> <p>Causes: 1. Slow network loading Monaco assets 2. JavaScript bundle corruption 3. Browser compatibility issues</p> <p>Solutions:</p> <ol> <li>Wait for loading:</li> <li>Monaco Editor takes 2-5 seconds to load</li> <li> <p>Wait for editor to fully initialize before interacting</p> </li> <li> <p>Check network requests:</p> </li> <li>Open DevTools Network tab</li> <li>Look for failed requests to Monaco assets</li> <li>Failed requests indicated by red text and 4xx/5xx status</li> <li> <p>If failed, refresh page to retry</p> </li> <li> <p>Clear browser cache:</p> </li> <li>Hard refresh (Ctrl+Shift+R)</li> <li>Or clear browser cache entirely</li> <li> <p>Reload page to fetch fresh assets</p> </li> <li> <p>Update browser:</p> </li> <li>Monaco requires modern browser (Chrome 90+, Firefox 88+, Edge 90+)</li> <li>Update browser to latest version</li> <li> <p>Restart browser after update</p> </li> <li> <p>Use alternative tabs:</p> </li> <li>If Monaco fails, use Settings or Navigation tabs instead</li> <li>Both provide same functionality without Monaco Editor</li> </ol>"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#problem-campaign-links-not-appearing-in-dropdown","title":"Problem: Campaign Links Not Appearing in Dropdown","text":"<p>Symptoms: - Click \"Add Campaign Link\" - Modal appears but dropdown is empty - No campaigns to select</p> <p>Causes: 1. No active campaigns in database 2. API error fetching campaigns 3. Insufficient permissions</p> <p>Solutions:</p> <ol> <li>Verify active campaigns exist:</li> <li>Navigate to \"Influence\" \u2192 \"Campaigns\" in sidebar</li> <li>Check if any campaigns have \"Active\" status</li> <li>If none, create active campaign first</li> <li> <p>Return to MkDocs Settings and try again</p> </li> <li> <p>Check browser console:</p> </li> <li>Open DevTools (F12)</li> <li>Click \"Add Campaign Link\"</li> <li>Check Console for errors</li> <li> <p>Look for API errors (401, 403, 500)</p> </li> <li> <p>Verify permissions:</p> </li> <li>Campaign link insertion requires <code>SUPER_ADMIN</code> role</li> <li>Check user role in profile dropdown</li> <li> <p>If not <code>SUPER_ADMIN</code>, request role upgrade from administrator</p> </li> <li> <p>Check API endpoint:</p> </li> <li>Open DevTools Network tab</li> <li>Click \"Add Campaign Link\"</li> <li>Look for <code>GET /api/influence/campaigns</code> request</li> <li>Check Response tab for campaign data</li> <li> <p>If empty response, no campaigns available</p> </li> <li> <p>Manually add campaign pages:</p> </li> <li>Instead of campaign link button, use \"Add Page\" button</li> <li>Manually enter campaign details:<ul> <li>Title: \"Climate Action Now\"</li> <li>File: <code>campaigns/climate-action-now.md</code></li> </ul> </li> <li>Create markdown file manually in <code>mkdocs/docs/campaigns/</code></li> </ol>"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#backend-documentation","title":"Backend Documentation","text":"<ul> <li>Docs Routes - API endpoints for MkDocs configuration and build</li> <li>Pages Module - Landing page system (related to MkDocs export)</li> </ul>"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#frontend-documentation","title":"Frontend Documentation","text":"<ul> <li>DocsPage - MkDocs export management (generates pages for MkDocs)</li> <li>LandingPagesPage - Landing page editor (exports to MkDocs)</li> <li>AppLayout - Sidebar navigation structure</li> </ul>"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#feature-documentation","title":"Feature Documentation","text":"<ul> <li>Documentation System - Complete docs architecture</li> <li>MkDocs Integration - MkDocs Material theme setup</li> <li>Content Management - Content creation workflow</li> </ul>"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#api-documentation","title":"API Documentation","text":"<ul> <li>GET /api/docs/config - Fetch MkDocs configuration</li> <li>PUT /api/docs/config - Update MkDocs configuration</li> <li>POST /api/docs/build - Trigger static site build</li> <li>GET /api/docs/orphaned-files - List markdown files not in navigation</li> <li>GET /api/influence/campaigns - Fetch campaigns</li> </ul>"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#user-guides","title":"User Guides","text":"<ul> <li>Admin Guide - Complete admin workflows</li> <li>Content Editor Guide - Documentation authoring best practices</li> </ul>"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#deployment-documentation","title":"Deployment Documentation","text":"<ul> <li>MkDocs Setup - Docker container configuration</li> <li>Nginx Configuration - Subdomain routing for docs.cmlite.org</li> </ul>"},{"location":"v2/frontend/pages/admin/mkdocs-settings-page/#development-documentation","title":"Development Documentation","text":"<ul> <li>Local Setup - Running MkDocs dev server</li> <li>Monaco Editor Integration - Code editor setup patterns</li> </ul>"},{"location":"v2/frontend/pages/admin/n8n-page/","title":"N8nPage","text":""},{"location":"v2/frontend/pages/admin/n8n-page/#overview","title":"Overview","text":"<p>File: <code>admin/src/pages/N8nPage.tsx</code></p> <p>Route: <code>/app/services/n8n</code></p> <p>Role Requirements: Any authenticated user (uses <code>authenticate</code> middleware)</p> <p>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.</p> <p>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</p> <p>Layout: AppLayout with fullbleed</p> <p>Dependencies: - Ant Design v5 (Button, Space, Badge, Spin, Grid, Result) - Service URL builder utility</p>"},{"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":"<p>Status Display: - Green \"Online\" badge when n8n is accessible - Red \"Offline\" badge when unavailable - Blue \"Checking...\" badge during status check</p>"},{"location":"v2/frontend/pages/admin/n8n-page/#2-mobile-device-detection","title":"2. Mobile Device Detection","text":"<p>Mobile Warning: - ApiOutlined icon (48px) - Message: \"The workflow editor requires a desktop browser\" - \"Open in New Tab\" button for external access</p>"},{"location":"v2/frontend/pages/admin/n8n-page/#3-workflow-automation","title":"3. Workflow Automation","text":"<p>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</p> <p>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</p>"},{"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":"<ol> <li>Navigate to Workflow Automation:</li> <li>Click \"Services\" \u2192 \"Workflow Automation\" in sidebar</li> <li> <p>Page loads with status check</p> </li> <li> <p>Wait for Load:</p> </li> <li>Status badge shows \"Checking...\" then \"Online\"</li> <li> <p>n8n editor loads in iframe</p> </li> <li> <p>Create New Workflow:</p> </li> <li>Click \"New Workflow\" button in n8n</li> <li> <p>Empty canvas appears</p> </li> <li> <p>Add Trigger Node:</p> </li> <li>Click \"+\" button on canvas</li> <li> <p>Select trigger type:</p> <ul> <li>Schedule: Run on cron schedule (e.g., daily at 9am)</li> <li>Webhook: Trigger via HTTP POST</li> <li>Manual: Run manually via button</li> </ul> </li> <li> <p>Add Action Nodes:</p> </li> <li>Click \"+\" button after trigger</li> <li>Search for integration (e.g., \"PostgreSQL\", \"Gmail\", \"HTTP Request\")</li> <li> <p>Configure node:</p> <ul> <li>Select credentials (API keys, database connection)</li> <li>Set parameters (SQL query, email recipient, API endpoint)</li> <li>Map data from previous nodes</li> </ul> </li> <li> <p>Test Workflow:</p> </li> <li>Click \"Execute Workflow\" button</li> <li>View execution result for each node</li> <li>Check output data</li> <li> <p>Debug errors if any</p> </li> <li> <p>Activate Workflow:</p> </li> <li>Toggle \"Active\" switch in top-right</li> <li> <p>Workflow now runs automatically based on trigger</p> </li> <li> <p>Monitor Executions:</p> </li> <li>Click \"Executions\" tab</li> <li>View history of all workflow runs</li> <li>Click execution to see detailed logs</li> <li>Identify failed executions</li> </ol>"},{"location":"v2/frontend/pages/admin/n8n-page/#example-workflows","title":"Example Workflows","text":"<p>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)</p> <p>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)</p> <p>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)</p>"},{"location":"v2/frontend/pages/admin/n8n-page/#component-structure","title":"Component Structure","text":"<pre><code>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</code></pre>"},{"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":"<ol> <li>GET /api/services/status - Check n8n health</li> <li>GET /api/services/config - Fetch subdomain/port config</li> </ol>"},{"location":"v2/frontend/pages/admin/n8n-page/#example-responses","title":"Example Responses","text":"<p>Status: <pre><code>{\n \"n8n\": { \"online\": true },\n \"mailhog\": { \"online\": true },\n \"nocodb\": { \"online\": true }\n}\n</code></pre></p> <p>Config: <pre><code>{\n \"domain\": \"cmlite.org\",\n \"n8nSubdomain\": \"n8n\",\n \"n8nPort\": 5678\n}\n</code></pre></p> <p>Service URL: - Production: <code>http://n8n.cmlite.org</code> - Development: <code>http://localhost:5678</code></p>"},{"location":"v2/frontend/pages/admin/n8n-page/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/frontend/pages/admin/n8n-page/#problem-n8n-login-required","title":"Problem: n8n Login Required","text":"<p>Symptoms: - Iframe shows n8n login screen - Cannot access workflows without credentials</p> <p>Solutions:</p> <ol> <li>Check n8n credentials:</li> <li>Username: <code>N8N_BASIC_AUTH_USER</code> env var</li> <li> <p>Password: <code>N8N_BASIC_AUTH_PASSWORD</code> env var</p> </li> <li> <p>Login manually:</p> </li> <li>Enter credentials in n8n login form</li> <li> <p>n8n saves session in browser cookies</p> </li> <li> <p>Disable authentication (dev only):</p> </li> <li>Set <code>N8N_BASIC_AUTH_ACTIVE=false</code> in <code>.env</code></li> <li>Restart n8n: <code>docker compose restart n8n</code></li> </ol>"},{"location":"v2/frontend/pages/admin/n8n-page/#problem-workflow-execution-failed","title":"Problem: Workflow Execution Failed","text":"<p>Symptoms: - Workflow shows red error icon - Execution stopped at specific node - Error message displayed</p> <p>Solutions:</p> <ol> <li>Check node configuration:</li> <li>Click failed node</li> <li>Review parameters</li> <li> <p>Verify credentials valid</p> </li> <li> <p>Check credentials:</p> </li> <li>Click \"Credentials\" in n8n sidebar</li> <li>Test credential connection</li> <li> <p>Re-enter if expired</p> </li> <li> <p>View error details:</p> </li> <li>Click execution in history</li> <li>Expand failed node</li> <li>Read error message</li> <li> <p>Common errors:</p> <ul> <li>\"Connection refused\": Service not accessible</li> <li>\"Unauthorized\": Invalid API key/credentials</li> <li>\"Timeout\": Request took too long</li> </ul> </li> <li> <p>Test individual nodes:</p> </li> <li>Right-click node \u2192 \"Execute Node\"</li> <li>Test each node in isolation</li> <li>Identify problematic node</li> </ol>"},{"location":"v2/frontend/pages/admin/n8n-page/#problem-webhook-not-triggering","title":"Problem: Webhook Not Triggering","text":"<p>Symptoms: - Webhook workflow not executing - External service sending webhooks but n8n not responding</p> <p>Solutions:</p> <ol> <li>Check webhook URL:</li> <li>Copy webhook URL from n8n trigger node</li> <li>Example: <code>http://n8n.cmlite.org/webhook/response</code></li> <li> <p>Verify URL accessible from external service</p> </li> <li> <p>Check workflow active:</p> </li> <li>Toggle \"Active\" switch must be ON</li> <li> <p>Inactive workflows don't respond to webhooks</p> </li> <li> <p>Test webhook manually: <pre><code>curl -X POST http://n8n.cmlite.org/webhook/response \\\n -H \"Content-Type: application/json\" \\\n -d '{\"test\": \"data\"}'\n</code></pre></p> </li> <li>Should return 200 OK</li> <li> <p>Check execution history in n8n</p> </li> <li> <p>Check nginx routing:</p> </li> <li>Webhook URL must route through nginx</li> <li>Verify proxy_pass configured for n8n</li> </ol>"},{"location":"v2/frontend/pages/admin/n8n-page/#related-documentation","title":"Related Documentation","text":"<ul> <li>n8n Setup - Docker configuration and credentials</li> <li>Workflow Examples - Pre-built workflows</li> <li>Services API - Status endpoints</li> <li>n8n Documentation - Official n8n docs (external)</li> </ul>"},{"location":"v2/frontend/pages/admin/nocodb-page/","title":"NocoDBPage","text":""},{"location":"v2/frontend/pages/admin/nocodb-page/#overview","title":"Overview","text":"<p>File: <code>admin/src/pages/NocoDBPage.tsx</code></p> <p>Route: <code>/app/services/nocodb</code></p> <p>Role Requirements: Any authenticated user (uses <code>authenticate</code> middleware)</p> <p>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.</p> <p>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</p> <p>Layout: AppLayout with fullbleed</p> <p>Dependencies: - Ant Design v5 (Button, Space, Badge, Spin, Grid, Result) - Service URL builder utility</p>"},{"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":"<p>Status Display: - Green \"Online\" badge when NocoDB is accessible - Red \"Offline\" badge when unavailable - Blue \"Checking...\" badge during status check</p>"},{"location":"v2/frontend/pages/admin/nocodb-page/#2-mobile-device-detection","title":"2. Mobile Device Detection","text":"<p>Mobile Warning: - DatabaseOutlined icon (48px) - Message: \"The database browser requires a desktop browser\" - \"Open in New Tab\" button for external access</p>"},{"location":"v2/frontend/pages/admin/nocodb-page/#3-database-browsing","title":"3. Database Browsing","text":"<p>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)</p> <p>Tables Available: - User, RefreshToken (auth) - Campaign, Representative, Response, CampaignEmail (influence) - Location, Cut, Shift, ShiftSignup (map) - CanvassSession, CanvassVisit (canvassing) - LandingPage, PageBlock (pages) - And more...</p>"},{"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":"<ol> <li>Navigate to Database Browser:</li> <li>Click \"Services\" \u2192 \"Database Browser\" in sidebar</li> <li> <p>Page loads with status check</p> </li> <li> <p>Wait for Load:</p> </li> <li>Status badge shows \"Checking...\" then \"Online\"</li> <li> <p>NocoDB interface loads in iframe</p> </li> <li> <p>Select Table:</p> </li> <li>Left sidebar lists all tables</li> <li> <p>Click table name to view contents</p> </li> <li> <p>View Table Data:</p> </li> <li>Spreadsheet view with all rows and columns</li> <li>Scroll horizontally/vertically</li> <li> <p>Click row to expand details</p> </li> <li> <p>Filter Data:</p> </li> <li>Click filter icon in column header</li> <li>Select filter condition (equals, contains, etc.)</li> <li>Enter filter value</li> <li> <p>View filtered results</p> </li> <li> <p>Export Data:</p> </li> <li>Click \"...\" menu in table header</li> <li>Select \"Export\" \u2192 \"CSV\" or \"Excel\"</li> <li> <p>Download file for offline analysis</p> </li> <li> <p>Common Use Cases:</p> </li> <li>User Management: Browse User table to see all accounts</li> <li>Campaign Analysis: View Campaign responses by filtering Response table</li> <li>Location Data: Export Location table for mapping analysis</li> <li>Audit Trail: Check RefreshToken table for login activity</li> </ol>"},{"location":"v2/frontend/pages/admin/nocodb-page/#component-structure","title":"Component Structure","text":"<pre><code>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</code></pre>"},{"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":"<ol> <li>GET /api/services/status - Check NocoDB health</li> <li>GET /api/services/config - Fetch subdomain/port config</li> </ol>"},{"location":"v2/frontend/pages/admin/nocodb-page/#example-responses","title":"Example Responses","text":"<p>Status: <pre><code>{\n \"nocodb\": { \"online\": true },\n \"mailhog\": { \"online\": true },\n \"n8n\": { \"online\": true }\n}\n</code></pre></p> <p>Config: <pre><code>{\n \"domain\": \"cmlite.org\",\n \"nocodbSubdomain\": \"db\",\n \"nocodbPort\": 8091\n}\n</code></pre></p> <p>Service URL: - Production: <code>http://db.cmlite.org</code> - Development: <code>http://localhost:8091</code></p>"},{"location":"v2/frontend/pages/admin/nocodb-page/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/frontend/pages/admin/nocodb-page/#problem-nocodb-login-required","title":"Problem: NocoDB Login Required","text":"<p>Symptoms: - Iframe shows NocoDB login screen - Cannot access tables without credentials</p> <p>Solutions:</p> <ol> <li>Check NocoDB admin credentials:</li> <li>Username: <code>NC_ADMIN_EMAIL</code> env var</li> <li> <p>Password: <code>NC_ADMIN_PASSWORD</code> env var</p> </li> <li> <p>Login manually:</p> </li> <li>Enter admin credentials in NocoDB login form</li> <li> <p>NocoDB saves session in browser cookies</p> </li> <li> <p>Reset password: <pre><code>docker compose exec nocodb sh\nnc-cli reset-password --email admin@example.com\n</code></pre></p> </li> </ol>"},{"location":"v2/frontend/pages/admin/nocodb-page/#problem-tables-not-visible","title":"Problem: Tables Not Visible","text":"<p>Symptoms: - NocoDB loads but no tables in left sidebar - \"No projects found\" message</p> <p>Solutions:</p> <ol> <li>Check NocoDB configuration:</li> <li>NocoDB must be connected to PostgreSQL</li> <li> <p>Check <code>NC_DB</code> env var: <code>postgresql://user:password@host:port/database</code></p> </li> <li> <p>Create NocoDB project:</p> </li> <li>Click \"New Project\" button</li> <li>Connect to PostgreSQL database</li> <li> <p>Enter database credentials (from <code>V2_POSTGRES_*</code> env vars)</p> </li> <li> <p>Restart NocoDB: <pre><code>docker compose restart nocodb\n</code></pre></p> </li> </ol>"},{"location":"v2/frontend/pages/admin/nocodb-page/#related-documentation","title":"Related Documentation","text":"<ul> <li>NocoDB Setup - Docker configuration</li> <li>Database Schema - Complete table reference</li> <li>Services API - Status endpoints</li> </ul>"},{"location":"v2/frontend/pages/admin/observability-page/","title":"ObservabilityPage","text":""},{"location":"v2/frontend/pages/admin/observability-page/#overview","title":"Overview","text":"<p>File: <code>admin/src/pages/ObservabilityPage.tsx</code> Route: <code>/app/observability</code> Role Requirements: <code>SUPER_ADMIN</code></p> <p>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.</p> <p>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</p> <p>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</p> <p>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</p>"},{"location":"v2/frontend/pages/admin/observability-page/#screenshot","title":"Screenshot","text":"<p>[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.]</p>"},{"location":"v2/frontend/pages/admin/observability-page/#features","title":"Features","text":""},{"location":"v2/frontend/pages/admin/observability-page/#core-features","title":"Core Features","text":"<ol> <li>Three-Tab Interface</li> <li>Overview Tab: Service status + metrics + alerts summary</li> <li>Monitoring Tab: Embedded Grafana Application Overview dashboard</li> <li>Alerts Tab: Embedded Alertmanager UI</li> <li>Radio button switcher in page header</li> <li> <p>Tab state preserved during session</p> </li> <li> <p>Service Status Monitoring</p> </li> <li>7 service status cards:<ul> <li>Prometheus - Metrics database</li> <li>Grafana - Dashboard visualization</li> <li>Alertmanager - Alert management</li> <li>cAdvisor - Container metrics</li> <li>Node Exporter - Host metrics</li> <li>Redis Exporter - Redis metrics</li> <li>Gotify - Notification service</li> </ul> </li> <li>Online/offline badge indicators</li> <li>Clickable URL to open service in new tab</li> <li> <p>Responsive grid layout (4 columns on desktop, 2 on tablet, 1 on mobile)</p> </li> <li> <p>Auto-Start Banner</p> </li> <li>Warning alert at top of Overview tab when all services offline</li> <li>Shows Docker Compose command to start monitoring services</li> <li>Command: <code>docker compose --profile monitoring up -d</code></li> <li> <p>Only shows when <code>servicesOnline === 0</code></p> </li> <li> <p>Key Metrics Grid</p> </li> <li>Displays application-specific metrics from Prometheus</li> <li>Examples: API uptime, email queue size, active canvass sessions, total locations</li> <li>Only visible when at least one service online</li> <li> <p>Powered by MetricsGrid component</p> </li> <li> <p>Active Alerts Table</p> </li> <li>Shows currently firing alerts from Alertmanager</li> <li>Columns: Alert name, severity, status, start time</li> <li>Color-coded severity (critical=red, warning=orange, info=blue)</li> <li>Only visible when at least one service online</li> <li> <p>Powered by AlertsTable component</p> </li> <li> <p>Grafana Dashboard Iframe</p> </li> <li>Embedded Application Overview dashboard</li> <li>Lazy-loaded (only loads when Monitoring tab selected)</li> <li>Full-height iframe (<code>calc(100vh - 200px)</code>)</li> <li>Sandboxed for security (<code>allow-scripts</code>, <code>allow-same-origin</code>, <code>allow-forms</code>)</li> <li>Error boundary for graceful failure handling</li> <li> <p>Shows warning if Grafana offline</p> </li> <li> <p>Alertmanager Iframe</p> </li> <li>Embedded Alertmanager UI</li> <li>Lazy-loaded (only loads when Alerts tab selected)</li> <li>Full-height iframe (<code>calc(100vh - 200px)</code>)</li> <li>Sandboxed for security</li> <li>Error boundary for graceful failure handling</li> <li> <p>Shows warning if Alertmanager offline</p> </li> <li> <p>Refresh Button</p> </li> <li>Refreshes all data (status, metrics, alerts) in parallel</li> <li>Visible in all tabs</li> <li> <p>Loading state during refresh</p> </li> <li> <p>Open Grafana Button</p> </li> <li>Primary button in header (blue)</li> <li>Opens Grafana in new tab at full URL</li> <li>Only visible when Grafana online</li> <li>Provides full-screen Grafana access</li> </ol>"},{"location":"v2/frontend/pages/admin/observability-page/#user-workflow","title":"User Workflow","text":""},{"location":"v2/frontend/pages/admin/observability-page/#viewing-system-status-overview-tab","title":"Viewing System Status (Overview Tab)","text":"<ol> <li>Navigate to page: Admin sidebar \u2192 System \u2192 Observability</li> <li>Overview tab loads: Shows service status cards, metrics grid, alerts table</li> <li>Check service status: Green badges = online, red badges = offline</li> <li>Review metrics: Scan key application metrics (uptime, queue size, etc.)</li> <li>Check alerts: Review active alerts table for firing alerts</li> </ol>"},{"location":"v2/frontend/pages/admin/observability-page/#starting-monitoring-services","title":"Starting Monitoring Services","text":"<p>If all services offline: 1. See warning banner: Yellow alert at top with Docker Compose command 2. Copy command: <code>docker compose --profile monitoring up -d</code> 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</p>"},{"location":"v2/frontend/pages/admin/observability-page/#viewing-grafana-dashboards","title":"Viewing Grafana Dashboards","text":"<ol> <li>Click \"Monitoring\" tab: Radio button in header</li> <li>Grafana iframe loads: Embedded Application Overview dashboard</li> <li>Interact with dashboard: Pan, zoom, change time range, etc.</li> <li>Full-screen access: Click \"Open Grafana\" button for new tab</li> <li>Explore more dashboards: In Grafana UI, browse other dashboards (Host Metrics, Docker Containers, etc.)</li> </ol>"},{"location":"v2/frontend/pages/admin/observability-page/#managing-alerts","title":"Managing Alerts","text":"<ol> <li>Click \"Alerts\" tab: Radio button in header</li> <li>Alertmanager iframe loads: Embedded alert management UI</li> <li>View alert groups: See all firing alerts grouped by label</li> <li>Silence alerts: Click Silence button to temporarily suppress</li> <li>Configure routes: Modify alert routing rules (if SUPER_ADMIN)</li> </ol>"},{"location":"v2/frontend/pages/admin/observability-page/#refreshing-data","title":"Refreshing Data","text":"<ol> <li>Click Refresh button: In header (any tab)</li> <li>All data reloads: Service status, metrics, alerts fetched in parallel</li> <li>Loading state: Brief spinner or loading indicator</li> <li>Data updates: New status/metrics/alerts displayed</li> </ol>"},{"location":"v2/frontend/pages/admin/observability-page/#opening-service-directly","title":"Opening Service Directly","text":"<ol> <li>Click on service status card URL (if service online)</li> <li>New tab opens: Direct access to service (e.g., Prometheus, Grafana, Alertmanager)</li> <li>Full service UI: No iframe restrictions, full functionality</li> </ol>"},{"location":"v2/frontend/pages/admin/observability-page/#component-breakdown","title":"Component Breakdown","text":""},{"location":"v2/frontend/pages/admin/observability-page/#tab-switcher-header","title":"Tab Switcher (Header)","text":"<pre><code><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</code></pre> <p>Solid button style: Active tab highlighted with blue background.</p>"},{"location":"v2/frontend/pages/admin/observability-page/#service-status-card","title":"Service Status Card","text":"<pre><code><ServiceStatusCard\n name=\"Prometheus\"\n online={status?.prometheus?.online || false}\n url={status?.prometheus?.url || ''}\n icon={<DashboardOutlined />}\n/>\n</code></pre> <p>ServiceStatusCard Component: <pre><code>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</code></pre></p>"},{"location":"v2/frontend/pages/admin/observability-page/#auto-start-banner","title":"Auto-Start Banner","text":"<pre><code>{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</code></pre> <p>Condition: <code>allOffline = servicesOnline === 0</code></p>"},{"location":"v2/frontend/pages/admin/observability-page/#service-status-grid","title":"Service Status Grid","text":"<pre><code><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</code></pre> <p>Responsive Grid: - Desktop (<code>lg</code>, \u2265 992px): 4 columns (6/24 = 25% width each) - Tablet (<code>sm</code>, \u2265 576px): 2 columns (12/24 = 50% width each) - Mobile (<code>xs</code>, < 576px): 1 column (24/24 = 100% width)</p>"},{"location":"v2/frontend/pages/admin/observability-page/#metrics-grid","title":"Metrics Grid","text":"<pre><code>{!allOffline && <MetricsGrid metrics={metrics} loading={loading} />}\n</code></pre> <p>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</p>"},{"location":"v2/frontend/pages/admin/observability-page/#alerts-table","title":"Alerts Table","text":"<pre><code>{!allOffline && alerts && (\n <AlertsTable alerts={alerts.alerts || []} loading={loading} />\n)}\n</code></pre> <p>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</p>"},{"location":"v2/frontend/pages/admin/observability-page/#grafana-iframe-monitoring-tab","title":"Grafana Iframe (Monitoring Tab)","text":"<pre><code><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</code></pre> <p>Lazy Loading Logic: <pre><code>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</code></pre></p> <p>Pattern: Iframe src set only when: 1. Monitoring tab selected 2. Not already initialized (ref tracks this) 3. Grafana is online</p>"},{"location":"v2/frontend/pages/admin/observability-page/#alertmanager-iframe-alerts-tab","title":"Alertmanager Iframe (Alerts Tab)","text":"<pre><code><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</code></pre> <p>Same lazy loading pattern as Grafana.</p>"},{"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":"<p>Data State: <pre><code>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</code></pre></p> <p>UI State: <pre><code>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</code></pre></p>"},{"location":"v2/frontend/pages/admin/observability-page/#data-fetching","title":"Data Fetching","text":"<p>Fetch Status: <pre><code>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</code></pre></p> <p>Fetch Metrics: <pre><code>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</code></pre></p> <p>Fetch Alerts: <pre><code>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</code></pre></p> <p>Fetch All (Parallel): <pre><code>const fetchAll = useCallback(async () => {\n setLoading(true);\n await Promise.all([fetchStatus(), fetchMetrics(), fetchAlerts()]);\n setLoading(false);\n}, [fetchStatus, fetchMetrics, fetchAlerts]);\n</code></pre></p> <p>Benefit: Parallel API calls load faster than sequential.</p>"},{"location":"v2/frontend/pages/admin/observability-page/#lazy-iframe-loading","title":"Lazy Iframe Loading","text":"<pre><code>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</code></pre> <p>Why Lazy Loading? - Avoids loading heavy iframes until needed - Improves initial page load performance - Saves bandwidth if user never clicks Monitoring/Alerts tabs</p> <p>Why useRef? - Tracks initialization state without triggering re-renders - Prevents redundant iframe loads on subsequent tab switches</p>"},{"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":"<p>GET <code>/observability/status</code> - Fetch service online/offline status <pre><code>const { data } = await api.get<ObservabilityStatus>('/observability/status');\n</code></pre></p> <p>Response: <pre><code>{\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</code></pre></p> <p>GET <code>/observability/metrics-summary</code> - Fetch key application metrics <pre><code>const { data } = await api.get<MetricsSummary>('/observability/metrics-summary');\n</code></pre></p> <p>Response: <pre><code>{\n \"apiUptime\": 99.8,\n \"emailQueueSize\": 42,\n \"activeCanvassSessions\": 5,\n \"totalLocations\": 12543,\n \"httpRequestsTotal\": 156789,\n \"httpRequestDurationSeconds\": 0.234\n}\n</code></pre></p> <p>GET <code>/observability/alerts</code> - Fetch active alerts <pre><code>const { data } = await api.get<AlertsResponse>('/observability/alerts');\n</code></pre></p> <p>Response: <pre><code>{\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</code></pre></p>"},{"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":"<pre><code>const fetchAll = useCallback(async () => {\n setLoading(true);\n await Promise.all([fetchStatus(), fetchMetrics(), fetchAlerts()]);\n setLoading(false);\n}, [fetchStatus, fetchMetrics, fetchAlerts]);\n</code></pre> <p>Benefit: Loads all data simultaneously (faster than sequential).</p>"},{"location":"v2/frontend/pages/admin/observability-page/#lazy-iframe-loading-pattern","title":"Lazy Iframe Loading Pattern","text":"<pre><code>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</code></pre> <p>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)</p>"},{"location":"v2/frontend/pages/admin/observability-page/#services-online-count","title":"Services Online Count","text":"<pre><code>const servicesOnline = status\n ? Object.values(status).filter((s: ServiceStatus) => s.online).length\n : 0;\nconst allOffline = servicesOnline === 0;\n</code></pre> <p>Counts online services from status object values.</p>"},{"location":"v2/frontend/pages/admin/observability-page/#conditional-rendering-based-on-service-status","title":"Conditional Rendering Based on Service Status","text":"<pre><code>{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</code></pre> <p>Pattern: Show banner if all offline, hide metrics/alerts if all offline.</p>"},{"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":"<p>Three API calls made simultaneously instead of sequentially: <pre><code>await Promise.all([fetchStatus(), fetchMetrics(), fetchAlerts()]);\n</code></pre></p> <p>Benefit: Reduces total load time from ~300ms (100ms \u00d7 3) to ~100ms (max of 3 parallel requests).</p>"},{"location":"v2/frontend/pages/admin/observability-page/#lazy-iframe-loading_1","title":"Lazy Iframe Loading","text":"<p>Iframes only load when tab selected: - Grafana iframe: <code>activeTab === 'monitoring'</code> - Alertmanager iframe: <code>activeTab === 'alerts'</code></p> <p>Benefit: Saves bandwidth and reduces initial page load time. Heavy iframes (~1-2MB each) not loaded unless needed.</p>"},{"location":"v2/frontend/pages/admin/observability-page/#useref-for-initialization-tracking","title":"useRef for Initialization Tracking","text":"<pre><code>const grafanaInitialized = useRef(false);\n</code></pre> <p>Why useRef instead of useState? - Doesn't trigger re-renders when updated - Persists across re-renders - Perfect for tracking initialization state</p>"},{"location":"v2/frontend/pages/admin/observability-page/#conditional-component-rendering","title":"Conditional Component Rendering","text":"<pre><code>{!allOffline && <MetricsGrid metrics={metrics} loading={loading} />}\n</code></pre> <p>Avoids rendering heavy components when no services online (no data to show).</p>"},{"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":"<pre><code><Row gutter={[16, 16]}>\n <Col xs={24} sm={12} lg={6}>\n <ServiceStatusCard ... />\n </Col>\n {/* 6 more cards... */}\n</Row>\n</code></pre> <p>Responsive Breakpoints: - Desktop (<code>lg</code>, \u2265 992px): 4 columns (6/24 each) - Tablet (<code>sm</code>, \u2265 576px): 2 columns (12/24 each) - Mobile (<code>xs</code>, < 576px): 1 column (24/24 each)</p>"},{"location":"v2/frontend/pages/admin/observability-page/#iframe-height","title":"Iframe Height","text":"<pre><code><iframe style={{ height: 'calc(100vh - 200px)' }} />\n</code></pre> <p>Dynamic height: Fills viewport minus header/footer (responsive to window resize).</p>"},{"location":"v2/frontend/pages/admin/observability-page/#accessibility","title":"Accessibility","text":""},{"location":"v2/frontend/pages/admin/observability-page/#iframe-labels","title":"Iframe Labels","text":"<pre><code><iframe\n title=\"Grafana Dashboard\"\n aria-label=\"Embedded Grafana application overview dashboard\"\n/>\n</code></pre> <p>Screen reader support: Clear description of iframe content.</p>"},{"location":"v2/frontend/pages/admin/observability-page/#button-labels","title":"Button Labels","text":"<pre><code><Button icon={<ReloadOutlined />}>Refresh</Button>\n<Button icon={<LinkOutlined />}>Open Grafana</Button>\n</code></pre> <p>Not icon-only buttons \u2013 text labels for clarity.</p>"},{"location":"v2/frontend/pages/admin/observability-page/#service-status-badges","title":"Service Status Badges","text":"<pre><code><Badge status=\"success\" text=\"Online\" />\n<Badge status=\"error\" text=\"Offline\" />\n</code></pre> <p>Color + text: Not relying on color alone for status indication.</p>"},{"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":"<p>Symptoms: - Warning banner at top - All service status cards show red \"Offline\" - No metrics or alerts displayed</p> <p>Cause: Monitoring services not started (Docker Compose profile <code>monitoring</code> not active)</p> <p>Solution: <pre><code># 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</code></pre></p>"},{"location":"v2/frontend/pages/admin/observability-page/#grafanaalertmanager-iframe-not-loading","title":"Grafana/Alertmanager Iframe Not Loading","text":"<p>Symptoms: - Blank iframe or loading spinner forever - Console errors about iframe src</p> <p>Causes: 1. Service offline (check Overview tab status) 2. CORS policy blocking iframe 3. Network error</p> <p>Debug: <pre><code># 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</code></pre></p>"},{"location":"v2/frontend/pages/admin/observability-page/#metrics-not-showing","title":"Metrics Not Showing","text":"<p>Symptoms: - MetricsGrid empty or shows zeros - \"Failed to load metrics\" error</p> <p>Cause: Prometheus offline or not scraping metrics</p> <p>Solutions: <pre><code># 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</code></pre></p>"},{"location":"v2/frontend/pages/admin/observability-page/#alerts-not-showing","title":"Alerts Not Showing","text":"<p>Symptoms: - AlertsTable empty - No alerts firing (but should be)</p> <p>Causes: 1. Alertmanager offline 2. No alerts configured in Prometheus 3. Alerts resolved (not firing)</p> <p>Debug: <pre><code># 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</code></pre></p>"},{"location":"v2/frontend/pages/admin/observability-page/#open-grafana-button-not-visible","title":"\"Open Grafana\" Button Not Visible","text":"<p>Cause: Grafana offline</p> <p>Expected Behavior: <pre><code>{status?.grafana.online && (\n <Button href={status.grafana.url} target=\"_blank\">\n Open Grafana\n </Button>\n)}\n</code></pre></p> <p>Button only shows when Grafana online.</p>"},{"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":"<ul> <li>Observability Module - Service, routes, Prometheus integration</li> <li>Observability API Reference - Full endpoint documentation</li> </ul>"},{"location":"v2/frontend/pages/admin/observability-page/#features_1","title":"Features","text":"<ul> <li>Monitoring System - Feature overview</li> <li>Prometheus Metrics - Custom metrics documentation</li> <li>Alert Rules - Alert configuration</li> <li>Grafana Dashboards - Dashboard documentation</li> </ul>"},{"location":"v2/frontend/pages/admin/observability-page/#deployment","title":"Deployment","text":"<ul> <li>Monitoring Stack Deployment - Docker Compose setup</li> <li>Prometheus Configuration - Prometheus config</li> <li>Grafana Configuration - Grafana setup</li> </ul>"},{"location":"v2/frontend/pages/admin/observability-page/#troubleshooting_1","title":"Troubleshooting","text":"<ul> <li>Monitoring Issues - Common monitoring problems</li> <li>Docker Issues - Container troubleshooting</li> </ul>"},{"location":"v2/frontend/pages/admin/observability-page/#user-guides","title":"User Guides","text":"<ul> <li>Admin Guide - Observability - Monitoring workflows</li> </ul>"},{"location":"v2/frontend/pages/admin/observability-page/#external-resources","title":"External Resources","text":"<ul> <li>Prometheus Documentation - Prometheus reference</li> <li>Grafana Documentation - Grafana docs</li> <li>Alertmanager Documentation - Alertmanager reference</li> </ul>"},{"location":"v2/frontend/pages/admin/observability-page/#frontend-components","title":"Frontend Components","text":"<ul> <li>ServiceStatusCard Component - Status card documentation</li> <li>MetricsGrid Component - Metrics grid component</li> <li>AlertsTable Component - Alerts table component</li> <li>IframeErrorBoundary Component - Error boundary wrapper</li> </ul>"},{"location":"v2/frontend/pages/admin/page-editor-page/","title":"PageEditorPage","text":""},{"location":"v2/frontend/pages/admin/page-editor-page/#overview","title":"Overview","text":"<p>File: <code>admin/src/pages/PageEditorPage.tsx</code></p> <p>Route: <code>/app/pages/:id/edit</code></p> <p>Role Requirements: Any authenticated user (uses <code>authenticate</code> middleware)</p> <p>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.</p> <p>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</p> <p>Layout: Full-screen (no AppLayout wrapper)</p> <p>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)</p>"},{"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":"<p>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</p> <p>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)</p> <p>Mode Selection: - Set when creating page in LandingPagesPage - <code>editorMode</code> field: \"VISUAL\" or \"CODE\" - Cannot switch modes within editor (navigate back to pages list to change) - Mode displayed as colored tag in toolbar</p>"},{"location":"v2/frontend/pages/admin/page-editor-page/#2-toolbar-controls","title":"2. Toolbar Controls","text":"<p>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)</p> <p>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)</p>"},{"location":"v2/frontend/pages/admin/page-editor-page/#3-auto-save-keyboard-shortcuts","title":"3. Auto-Save & Keyboard Shortcuts","text":"<p>Code Mode Shortcuts: - Ctrl+S / Cmd+S - Save page (prevents browser default) - Keyboard event handler registered on mount - Handler cleaned up on unmount</p> <p>Visual Mode Save: - Save button triggers <code>editorRef.current?.triggerSave()</code> - GrapesJS editor handles internal save via forwardRef</p>"},{"location":"v2/frontend/pages/admin/page-editor-page/#4-mobile-device-detection","title":"4. Mobile Device Detection","text":"<p>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</p>"},{"location":"v2/frontend/pages/admin/page-editor-page/#5-loading-error-states","title":"5. Loading & Error States","text":"<p>Loading State: - Full-screen centered spinner - Displayed while fetching page data + blocks - Minimum height: 100vh</p> <p>Error Handling: - Failed fetch shows error message - Auto-navigates back to pages list - Prevents editor render on missing page</p>"},{"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":"<ol> <li>Navigate to Pages List:</li> <li>Go to <code>/app/pages</code> (LandingPagesPage)</li> <li> <p>View table of all landing pages</p> </li> <li> <p>Select Page to Edit:</p> </li> <li>Click \"Edit\" button in page row</li> <li>Opens editor in full-screen mode</li> <li> <p>URL changes to <code>/app/pages/:id/edit</code></p> </li> <li> <p>Wait for Editor Load:</p> </li> <li>Loading spinner appears</li> <li>Page data fetched from API</li> <li>Visual mode: block library also loaded</li> <li>Editor renders based on mode</li> </ol>"},{"location":"v2/frontend/pages/admin/page-editor-page/#editing-in-visual-mode","title":"Editing in Visual Mode","text":"<ol> <li>Use GrapesJS Interface:</li> <li>Add Components: Drag blocks from left sidebar onto canvas</li> <li>Move Components: Click and drag to reposition</li> <li>Edit Text: Double-click text to edit inline</li> <li>Style Components: Select component, use Style Manager in right panel</li> <li>Change Attributes: Use Trait Manager for component properties</li> <li> <p>Upload Images: Use Asset Manager to add media</p> </li> <li> <p>Canvas Controls:</p> </li> <li>Toggle device preview (desktop/tablet/mobile)</li> <li>Toggle fullscreen mode</li> <li> <p>Toggle borders/padding visualization</p> </li> <li> <p>Save Changes:</p> </li> <li>Click \"Save\" button in toolbar (or Ctrl+S)</li> <li>Editor extracts:<ul> <li>Project data (component tree JSON)</li> <li>Rendered HTML output</li> <li>Compiled CSS styles</li> </ul> </li> <li>All three sent to API via PUT request</li> <li>Success message: \"Page saved\"</li> </ol>"},{"location":"v2/frontend/pages/admin/page-editor-page/#editing-in-code-mode","title":"Editing in Code Mode","text":"<ol> <li>Edit HTML Directly:</li> <li>Monaco editor displays current HTML output</li> <li>Edit HTML structure, inline styles, content</li> <li> <p>Syntax highlighting for HTML tags</p> </li> <li> <p>Save Changes:</p> </li> <li>Press Ctrl+S (or Cmd+S on Mac)</li> <li>Or click \"Save\" button in toolbar</li> <li>Raw HTML content sent to API</li> <li> <p>Success message: \"Page saved\"</p> </li> <li> <p>Limitations:</p> </li> <li>No visual preview within editor</li> <li>Must publish and use Preview button to see changes</li> <li>Changes don't update GrapesJS project data (one-way sync)</li> </ol>"},{"location":"v2/frontend/pages/admin/page-editor-page/#publishing-a-page","title":"Publishing a Page","text":"<ol> <li>Toggle Published Switch:</li> <li>Switch in top-right toolbar</li> <li>Green = published, Gray = unpublished</li> <li> <p>API updates <code>published</code> field immediately</p> </li> <li> <p>When Published:</p> </li> <li>\"Live\" tag appears next to switch</li> <li>\"Preview\" button becomes visible</li> <li> <p>Page accessible at <code>/p/:slug</code> URL</p> </li> <li> <p>Preview Published Page:</p> </li> <li>Click \"Preview\" button (eye icon)</li> <li>Opens new browser tab to <code>/p/:slug</code></li> <li>Shows rendered page as public users see it</li> </ol>"},{"location":"v2/frontend/pages/admin/page-editor-page/#component-breakdown","title":"Component Breakdown","text":""},{"location":"v2/frontend/pages/admin/page-editor-page/#main-component-structure","title":"Main Component Structure","text":"<pre><code>export default function PageEditorPage() {\n const { id } = useParams<{ id: string }>();\n const navigate = useNavigate();\n const screens = Grid.useBreakpoint();\n const isMobile = !screens.md;\n const { token } = theme.useToken();\n\n // State\n const [page, setPage] = useState<LandingPage | null>(null);\n const [blocks, setBlocks] = useState<PageBlock[]>([]);\n const [loading, setLoading] = useState(true);\n const [saving, setSaving] = useState(false);\n const [codeContent, setCodeContent] = useState('');\n const editorRef = useRef<GrapesJSEditorHandle>(null);\n\n // Derived state\n const isCodeMode = page?.editorMode === 'CODE';\n\n // Fetch page + blocks (Visual mode only)\n useEffect(() => {\n const fetchData = async () => {\n try {\n if (isCodeMode) {\n const pageRes = await api.get<LandingPage>(`/pages/${id}`);\n setPage(pageRes.data);\n setCodeContent(pageRes.data.htmlOutput || '');\n } else {\n const [pageRes, blocksRes] = await Promise.all([\n api.get<LandingPage>(`/pages/${id}`),\n api.get<PageBlock[]>('/page-blocks'),\n ]);\n setPage(pageRes.data);\n setBlocks(blocksRes.data);\n setCodeContent(pageRes.data.htmlOutput || '');\n }\n } catch {\n message.error('Failed to load page');\n navigate('/app/pages');\n } finally {\n setLoading(false);\n }\n };\n fetchData();\n }, [id]);\n\n // Ctrl+S keyboard shortcut (code mode only)\n useEffect(() => {\n if (!isCodeMode) return;\n const handler = (e: KeyboardEvent) => {\n if ((e.ctrlKey || e.metaKey) && e.key === 's') {\n e.preventDefault();\n handleSaveCode();\n }\n };\n window.addEventListener('keydown', handler);\n return () => window.removeEventListener('keydown', handler);\n }, [isCodeMode, handleSaveCode]);\n\n // Conditional render based on state\n if (loading) return <Spin />;\n if (!page) return null;\n if (isMobile) return <MobileWarning />;\n\n return (\n <div style={{ height: '100vh' }}>\n <Toolbar />\n {isCodeMode ? <MonacoEditor /> : <GrapesJSEditor />}\n </div>\n );\n}\n</code></pre>"},{"location":"v2/frontend/pages/admin/page-editor-page/#toolbar-component","title":"Toolbar Component","text":"<p>Structure: - Full-width sticky header - Dark background (<code>colorBgBase</code>) - Border bottom separator - Two-column layout (Space components)</p> <p>Left Section: <pre><code><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</code></pre></p> <p>Right Section: <pre><code><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</code></pre></p>"},{"location":"v2/frontend/pages/admin/page-editor-page/#grapesjs-editor-integration","title":"GrapesJS Editor Integration","text":"<p>Component: <pre><code><GrapesJSEditor\n ref={editorRef}\n initialData={page.blocks as Record<string, unknown>}\n onSave={handleSaveVisual}\n customBlocks={blocks}\n/>\n</code></pre></p> <p>Save Callback: <pre><code>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</code></pre></p> <p>Trigger Save (from parent): <pre><code>editorRef.current?.triggerSave();\n</code></pre></p>"},{"location":"v2/frontend/pages/admin/page-editor-page/#monaco-editor-integration","title":"Monaco Editor Integration","text":"<p>Component: <pre><code><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</code></pre></p> <p>Save Handler: <pre><code>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</code></pre></p>"},{"location":"v2/frontend/pages/admin/page-editor-page/#mobile-warning-component","title":"Mobile Warning Component","text":"<p>Conditional Render: <pre><code>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</code></pre></p> <p>Breakpoint Detection: - Uses <code>Grid.useBreakpoint()</code> hook - <code>isMobile = !screens.md</code> (screen width < 768px) - Early return prevents editor initialization</p>"},{"location":"v2/frontend/pages/admin/page-editor-page/#state-management","title":"State Management","text":""},{"location":"v2/frontend/pages/admin/page-editor-page/#local-component-state-usestate","title":"Local Component State (useState)","text":"<pre><code>// 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</code></pre>"},{"location":"v2/frontend/pages/admin/page-editor-page/#refs-useref","title":"Refs (useRef)","text":"<pre><code>// GrapesJS editor handle\nconst editorRef = useRef<GrapesJSEditorHandle>(null);\n</code></pre> <p>Why useRef? - GrapesJS editor controlled externally - Parent triggers save via <code>editorRef.current?.triggerSave()</code> - No re-renders when editor state changes - Performance optimization for large canvas</p>"},{"location":"v2/frontend/pages/admin/page-editor-page/#derived-state","title":"Derived State","text":"<pre><code>// 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</code></pre>"},{"location":"v2/frontend/pages/admin/page-editor-page/#state-flow","title":"State Flow","text":"<ol> <li>Component Mounts:</li> <li><code>loading</code> set to true</li> <li><code>useEffect</code> triggers fetch based on mode</li> <li>Visual mode: parallel fetch page + blocks</li> <li>Code mode: fetch page only</li> <li>Sets <code>page</code>, <code>blocks</code>, <code>codeContent</code></li> <li> <p>Sets <code>loading</code> to false</p> </li> <li> <p>User Edits Content:</p> </li> <li>Visual mode: GrapesJS manages internal state</li> <li> <p>Code mode: Monaco onChange updates <code>codeContent</code></p> </li> <li> <p>User Saves:</p> </li> <li>Visual mode: <code>editorRef.current?.triggerSave()</code> \u2192 <code>handleSaveVisual</code> callback</li> <li>Code mode: <code>handleSaveCode</code> directly</li> <li>Sets <code>saving</code> to true</li> <li>API PUT request</li> <li>Updates <code>page</code> with response</li> <li> <p>Sets <code>saving</code> to false</p> </li> <li> <p>User Toggles Published:</p> </li> <li>API PUT request with <code>published</code> field</li> <li>Updates <code>page</code> with response</li> <li>UI updates (Live tag, Preview button)</li> </ol>"},{"location":"v2/frontend/pages/admin/page-editor-page/#api-integration","title":"API Integration","text":""},{"location":"v2/frontend/pages/admin/page-editor-page/#endpoints-used","title":"Endpoints Used","text":"<ol> <li>GET /api/pages/:id - Fetch page data</li> <li>GET /api/page-blocks - Fetch custom block library (Visual mode only)</li> <li>PUT /api/pages/:id - Update page (save, publish)</li> </ol>"},{"location":"v2/frontend/pages/admin/page-editor-page/#api-calls","title":"API Calls","text":""},{"location":"v2/frontend/pages/admin/page-editor-page/#1-fetch-page-blocks-visual-mode","title":"1. Fetch Page + Blocks (Visual Mode)","text":"<pre><code>const [pageRes, blocksRes] = await Promise.all([\n api.get<LandingPage>(`/pages/${id}`),\n api.get<PageBlock[]>('/page-blocks'),\n]);\nsetPage(pageRes.data);\nsetBlocks(blocksRes.data);\nsetCodeContent(pageRes.data.htmlOutput || '');\n</code></pre> <p>LandingPage Response: <pre><code>{\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</code></pre></p> <p>PageBlock Response: <pre><code>[\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</code></pre></p>"},{"location":"v2/frontend/pages/admin/page-editor-page/#2-fetch-page-only-code-mode","title":"2. Fetch Page Only (Code Mode)","text":"<pre><code>const pageRes = await api.get<LandingPage>(`/pages/${id}`);\nsetPage(pageRes.data);\nsetCodeContent(pageRes.data.htmlOutput || '');\n</code></pre>"},{"location":"v2/frontend/pages/admin/page-editor-page/#3-save-visual-mode-changes","title":"3. Save Visual Mode Changes","text":"<pre><code>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</code></pre> <p>Request Body: <pre><code>{\n \"blocks\": {\n \"pages\": [...],\n \"styles\": [...],\n \"components\": [...]\n },\n \"htmlOutput\": \"<html>...</html>\",\n \"cssOutput\": \".container { ... }\"\n}\n</code></pre></p>"},{"location":"v2/frontend/pages/admin/page-editor-page/#4-save-code-mode-changes","title":"4. Save Code Mode Changes","text":"<pre><code>const { data: updated } = await api.put<LandingPage>(`/pages/${page.id}`, {\n htmlOutput: codeContent,\n});\nsetPage(updated);\n</code></pre> <p>Request Body: <pre><code>{\n \"htmlOutput\": \"<!DOCTYPE html>\\n<html>...</html>\"\n}\n</code></pre></p>"},{"location":"v2/frontend/pages/admin/page-editor-page/#5-toggle-published","title":"5. Toggle Published","text":"<pre><code>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</code></pre> <p>Request Body: <pre><code>{\n \"published\": true\n}\n</code></pre></p>"},{"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":"<pre><code>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</code></pre>"},{"location":"v2/frontend/pages/admin/page-editor-page/#keyboard-shortcut-handler","title":"Keyboard Shortcut Handler","text":"<pre><code>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</code></pre>"},{"location":"v2/frontend/pages/admin/page-editor-page/#conditional-api-fetch-pattern","title":"Conditional API Fetch Pattern","text":"<pre><code>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</code></pre>"},{"location":"v2/frontend/pages/admin/page-editor-page/#editor-ref-save-trigger","title":"Editor Ref Save Trigger","text":"<pre><code>// 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</code></pre>"},{"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":"<pre><code>const [pageRes, blocksRes] = await Promise.all([\n api.get<LandingPage>(`/pages/${id}`),\n api.get<PageBlock[]>('/page-blocks'),\n]);\n</code></pre> <p>Benefit: Reduces loading time by ~50% (2 sequential requests \u2192 1 parallel batch).</p>"},{"location":"v2/frontend/pages/admin/page-editor-page/#2-conditional-block-loading","title":"2. Conditional Block Loading","text":"<pre><code>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</code></pre> <p>Benefit: Saves unnecessary API call in code mode (blocks not used).</p>"},{"location":"v2/frontend/pages/admin/page-editor-page/#3-useref-for-editor-handle","title":"3. useRef for Editor Handle","text":"<pre><code>const editorRef = useRef<GrapesJSEditorHandle>(null);\n</code></pre> <p>Why useRef? - GrapesJS editor has large internal state (component tree, styles, assets) - <code>useRef</code> prevents re-renders when editor state changes - Parent only needs to trigger save, not track editor state - Performance critical for large page designs</p>"},{"location":"v2/frontend/pages/admin/page-editor-page/#4-usecallback-for-save-handlers","title":"4. useCallback for Save Handlers","text":"<pre><code>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</code></pre> <p>Benefit: Prevents unnecessary function recreation on every render.</p>"},{"location":"v2/frontend/pages/admin/page-editor-page/#5-early-mobile-detection","title":"5. Early Mobile Detection","text":"<pre><code>if (isMobile) {\n return <MobileWarning />; // No editor initialization\n}\n</code></pre> <p>Benefit: Skips heavy editor initialization on mobile devices (saves memory + CPU).</p>"},{"location":"v2/frontend/pages/admin/page-editor-page/#6-automatic-monaco-layout","title":"6. Automatic Monaco Layout","text":"<pre><code><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</code></pre> <p>Benefit: Reduces Monaco memory footprint by disabling minimap (can use 100MB+ on large files).</p>"},{"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":"<p>Breakpoint: - Uses <code>Grid.useBreakpoint()</code> hook - Mobile if <code>!screens.md</code> (screen width < 768px)</p> <p>Mobile Warning Screen: <pre><code>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</code></pre></p> <p>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</p>"},{"location":"v2/frontend/pages/admin/page-editor-page/#full-screen-layout","title":"Full-Screen Layout","text":"<p>No AppLayout Wrapper: - Page routed outside AppLayout component - Uses full viewport height (<code>100vh</code>) - No sidebar navigation - Maximizes editing canvas space</p> <p>Layout Structure: <pre><code><div style={{ display: 'flex', flexDirection: 'column', height: '100vh' }}>\n <Toolbar /> {/* Fixed header */}\n <Editor /> {/* Flex-grow to fill remaining space */}\n</div>\n</code></pre></p>"},{"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":"<ol> <li>Tab Key:</li> <li>Cycles through toolbar buttons (Back, Save, Preview, Toggle)</li> <li> <p>Enters editor focus (Monaco or GrapesJS canvas)</p> </li> <li> <p>Ctrl+S / Cmd+S:</p> </li> <li>Save shortcut (code mode only)</li> <li> <p>Prevents browser default \"Save Page As\" dialog</p> </li> <li> <p>GrapesJS Keyboard Shortcuts:</p> </li> <li>Ctrl+Z: Undo</li> <li>Ctrl+Shift+Z: Redo</li> <li>Delete: Remove selected component</li> <li> <p>Ctrl+C/V: Copy/paste components</p> </li> <li> <p>Monaco Editor Shortcuts:</p> </li> <li>Ctrl+S: Save (custom handler)</li> <li>Ctrl+F: Find</li> <li>Ctrl+H: Find and replace</li> <li>Ctrl+/: Toggle comment</li> <li>Alt+Up/Down: Move line up/down</li> </ol>"},{"location":"v2/frontend/pages/admin/page-editor-page/#aria-labels","title":"ARIA Labels","text":"<pre><code><Button\n type=\"text\"\n icon={<ArrowLeftOutlined />}\n onClick={() => navigate('/app/pages')}\n aria-label=\"Back to pages list\"\n/>\n</code></pre> <p>Screen Reader Announcements: - Button labels announced via aria-label - Switch state announced (\"Published\" / \"Unpublished\") - Tag colors announced by screen readers</p>"},{"location":"v2/frontend/pages/admin/page-editor-page/#focus-management","title":"Focus Management","text":"<p>Toolbar Focus Order: 1. Back button 2. Published switch 3. Preview button (if visible) 4. Save button</p> <p>Editor Focus: - Monaco: automatic focus management via Monaco API - GrapesJS: focus enters canvas on click</p>"},{"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":"<p>Symptoms: - Blank screen after loading spinner disappears - Console errors related to GrapesJS or Monaco</p> <p>Solutions:</p> <ol> <li>Check page data in API response: <pre><code>curl -H \"Authorization: Bearer <token>\" \\\n http://localhost:4000/api/pages/<page-id>\n</code></pre></li> <li> <p>Verify <code>blocks</code>, <code>htmlOutput</code>, <code>editorMode</code> fields exist</p> </li> <li> <p>Check browser console:</p> </li> <li>Open DevTools Console (F12)</li> <li>Look for JavaScript errors</li> <li> <p>Common errors:</p> <ul> <li>\"Cannot read property 'pages' of undefined\" \u2192 <code>blocks</code> field missing/corrupt</li> <li>\"Monaco Editor failed to load\" \u2192 CDN blocked or slow network</li> </ul> </li> <li> <p>Clear browser cache:</p> </li> <li>Monaco and GrapesJS cache resources</li> <li> <p>Ctrl+Shift+R (hard refresh)</p> </li> <li> <p>Check network tab:</p> </li> <li>Verify API requests complete successfully</li> <li>Verify block library loads (Visual mode)</li> </ol>"},{"location":"v2/frontend/pages/admin/page-editor-page/#problem-save-button-not-working","title":"Problem: Save Button Not Working","text":"<p>Symptoms: - Click Save button, no success message - Loading spinner appears but never completes - Console shows 400/500 errors</p> <p>Solutions:</p> <ol> <li>Check API request in Network tab:</li> <li>Look for PUT <code>/api/pages/:id</code> request</li> <li>Check request payload (should have <code>htmlOutput</code>, <code>blocks</code>, <code>cssOutput</code>)</li> <li> <p>Check response status code</p> </li> <li> <p>Visual Mode - Invalid blocks data:</p> </li> <li>GrapesJS may generate invalid JSON</li> <li>Check console for serialization errors</li> <li> <p>Try creating new page instead of editing corrupt one</p> </li> <li> <p>Code Mode - Invalid HTML:</p> </li> <li>API may validate HTML structure</li> <li>Check for missing closing tags</li> <li> <p>Check for script injection attempts (blocked by CSP)</p> </li> <li> <p>Network timeout:</p> </li> <li>Large pages (>1MB HTML) may timeout</li> <li>Increase Axios timeout in <code>admin/src/lib/api.ts</code></li> <li>Optimize HTML output (minify, remove unused CSS)</li> </ol>"},{"location":"v2/frontend/pages/admin/page-editor-page/#problem-ctrls-not-saving-code-mode","title":"Problem: Ctrl+S Not Saving (Code Mode)","text":"<p>Symptoms: - Press Ctrl+S, nothing happens - Browser \"Save Page As\" dialog appears instead</p> <p>Solutions:</p> <ol> <li>Check browser focus:</li> <li>Ensure Monaco editor is focused (click inside editor)</li> <li> <p>Keyboard handler requires window focus</p> </li> <li> <p>Check browser extensions:</p> </li> <li>Extensions may intercept Ctrl+S</li> <li>Test in incognito mode</li> <li> <p>Disable extensions one by one</p> </li> <li> <p>Mac users: Use Cmd+S instead of Ctrl+S</p> </li> <li> <p>Handler supports both <code>e.ctrlKey</code> and <code>e.metaKey</code></p> </li> <li> <p>Manual save as fallback:</p> </li> <li>Click \"Save\" button in toolbar</li> <li>Same effect as Ctrl+S</li> </ol>"},{"location":"v2/frontend/pages/admin/page-editor-page/#problem-published-page-not-accessible","title":"Problem: Published Page Not Accessible","text":"<p>Symptoms: - Toggle \"Published\" switch to ON - Navigate to <code>/p/:slug</code>, get 404 error</p> <p>Solutions:</p> <ol> <li>Check slug uniqueness:</li> <li>Slug must be unique across all pages</li> <li> <p>Check for URL conflicts with existing routes</p> </li> <li> <p>Check page published status: <pre><code>curl -H \"Authorization: Bearer <token>\" \\\n http://localhost:4000/api/pages/<page-id>\n</code></pre></p> </li> <li> <p>Verify <code>\"published\": true</code> in response</p> </li> <li> <p>Check public route registration:</p> </li> <li>Open <code>admin/src/App.tsx</code></li> <li> <p>Verify public route exists: <pre><code><Route path=\"/p/:slug\" element={<LandingPage />} />\n</code></pre></p> </li> <li> <p>Check nginx routing:</p> </li> <li>Public pages served through nginx</li> <li> <p>Verify nginx reverse proxy configuration</p> </li> <li> <p>Hard refresh public page:</p> </li> <li>Ctrl+Shift+R to bypass cache</li> <li>Browser may cache 404 response</li> </ol>"},{"location":"v2/frontend/pages/admin/page-editor-page/#problem-grapesjs-not-loading-custom-blocks","title":"Problem: GrapesJS Not Loading Custom Blocks","text":"<p>Symptoms: - Visual editor loads but block panel is empty - Only default blocks visible (Text, Image, etc.)</p> <p>Solutions:</p> <ol> <li>Check blocks API response: <pre><code>curl -H \"Authorization: Bearer <token>\" \\\n http://localhost:4000/api/page-blocks\n</code></pre></li> <li> <p>Should return array of blocks with <code>label</code>, <code>content</code>, <code>media</code></p> </li> <li> <p>Check blocks passed to GrapesJS:</p> </li> <li>Add console.log in PageEditorPage: <pre><code>console.log('Custom blocks:', blocks);\n</code></pre></li> <li> <p>Verify array not empty</p> </li> <li> <p>Check GrapesJS block registration:</p> </li> <li>Open <code>admin/src/components/GrapesJSEditor.tsx</code></li> <li> <p>Verify blocks registered in <code>editor.BlockManager.add()</code></p> </li> <li> <p>Clear GrapesJS localStorage:</p> </li> <li>GrapesJS caches project data</li> <li>Open DevTools \u2192 Application \u2192 Local Storage</li> <li>Delete keys starting with <code>gjsProject-</code></li> </ol>"},{"location":"v2/frontend/pages/admin/page-editor-page/#related-documentation","title":"Related Documentation","text":"<ul> <li>LandingPagesPage - Page list + create new page</li> <li>Landing Pages Feature - Full feature documentation</li> <li>Pages API - API endpoints</li> <li>GrapesJSEditor Component - Editor wrapper</li> <li>Block Library - Custom block system</li> <li>Public Landing Pages - Rendered page component</li> <li>MkDocs Export - Export to documentation site</li> </ul>"},{"location":"v2/frontend/pages/admin/pangolin-page/","title":"PangolinPage","text":""},{"location":"v2/frontend/pages/admin/pangolin-page/#overview","title":"Overview","text":"<p>File: <code>admin/src/pages/PangolinPage.tsx</code> Route: <code>/app/tunnel</code> Role Requirements: <code>SUPER_ADMIN</code></p> <p>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.</p> <p>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</p> <p>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</p>"},{"location":"v2/frontend/pages/admin/pangolin-page/#screenshot","title":"Screenshot","text":"<p>[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.]</p>"},{"location":"v2/frontend/pages/admin/pangolin-page/#features","title":"Features","text":""},{"location":"v2/frontend/pages/admin/pangolin-page/#core-features","title":"Core Features","text":"<ol> <li>Status Monitoring</li> <li>Pangolin API configuration check (Configured: Yes/No)</li> <li>Server health check (Healthy/Unreachable)</li> <li>API URL display with copy button</li> <li>Newt container status (Ready/Running/Stopped/Not configured)</li> <li>Organization ID and Site ID display</li> <li> <p>Auto-refresh on status change</p> </li> <li> <p>Setup Wizard (First-Time Configuration)</p> </li> <li>Site name input (defaults to <code>changemaker-{domain}</code>)</li> <li>Subnet allocation with auto-suggestion (calculates next available subnet)</li> <li>Exit node selection (optional, only shown if exit nodes available)</li> <li>Online/offline exit node status indicators</li> <li>Auto-select single online exit node</li> <li>Create Site + Resources button</li> <li> <p>Post-setup credential display with show/hide toggle</p> </li> <li> <p>Credential Management</p> </li> <li>Shows PANGOLIN_SITE_ID and NEWT_* credentials after setup</li> <li>Show/Hide credentials button (security)</li> <li>Copy to Clipboard button</li> <li>Clear Credentials button</li> <li>Step-by-step instructions for .env setup</li> <li> <p>Newt container restart button after credential update</p> </li> <li> <p>Resource Management</p> </li> <li>Table showing all tunnel resources (subdomains)</li> <li>Columns: Name, Domain (copyable), SSL status, Active status, Port, Protocol, Blocked</li> <li>Edit button opens modal for resource configuration</li> <li>Delete button with Popconfirm</li> <li>Sync Resources button (creates missing resources from docker-compose.yml)</li> <li> <p>Restart Newt button in table header</p> </li> <li> <p>Resource Editing</p> </li> <li>Edit modal with form fields:<ul> <li>Name (text input)</li> <li>Protocol (e.g., http, https)</li> <li>Proxy Port (number input)</li> <li>Enable SSL (checkbox)</li> <li>Active (checkbox)</li> <li>Block Access (checkbox for maintenance mode)</li> </ul> </li> <li> <p>Update button saves changes to Pangolin API</p> </li> <li> <p>Exit Node Support</p> </li> <li>Fetches available exit nodes from Pangolin API</li> <li>Displays exit node name and location</li> <li>Shows online/offline status</li> <li>Filters out offline nodes (with user notice)</li> <li>Auto-selects if only one online node available</li> <li> <p>Graceful fallback if no exit nodes (self-hosted setups)</p> </li> <li> <p>Newt Container Management</p> </li> <li>Status monitoring (running/stopped/ready)</li> <li>Restart button in Status card</li> <li>Restart button in Resource table header</li> <li>3-second delay after restart before status check</li> <li> <p>Success/error messages for restart operations</p> </li> <li> <p>Subnet Auto-Suggestion</p> </li> <li>Fetches existing sites from Pangolin API</li> <li>Parses last subnet (e.g., <code>100.90.128.2/24</code>)</li> <li>Suggests next available subnet (increments last octet)</li> <li>Defaults to <code>100.90.128.3/24</code> if no sites exist</li> <li> <p>Allows manual override if suggested subnet conflicts</p> </li> <li> <p>Security Features</p> </li> <li>Credentials hidden by default</li> <li>Show/Hide toggle for sensitive values</li> <li>Clear button to remove credentials from screen</li> <li>Text sanitization for external API data (defense-in-depth)</li> </ol>"},{"location":"v2/frontend/pages/admin/pangolin-page/#user-workflow","title":"User Workflow","text":""},{"location":"v2/frontend/pages/admin/pangolin-page/#initial-setup-first-time-configuration","title":"Initial Setup (First-Time Configuration)","text":"<ol> <li>Navigate to page: Admin sidebar \u2192 System \u2192 Tunnel Management</li> <li>Check status: If \"Configured: No\", see blue info alert</li> <li>Configure .env: Add PANGOLIN_API_URL, PANGOLIN_API_KEY, PANGOLIN_ORG_ID to .env</li> <li>Restart API: <code>docker compose restart api</code></li> <li>Reload page: Status card shows \"Configured: Yes\"</li> <li>Fill setup form:</li> <li>Site Name: <code>changemaker-cmlite.org</code> (or custom)</li> <li>Subnet: <code>100.90.128.3/24</code> (auto-suggested, or override)</li> <li>Exit Node: Select if available (or leave empty for self-hosted)</li> <li>Click \"Create Site + Resources\"</li> <li>Wait for setup: Loading spinner, ~5-10 seconds</li> <li>View credentials: Success alert shows PANGOLIN_SITE_ID and NEWT_* values</li> <li>Show credentials: Click \"Show Credentials\" button</li> <li>Copy to .env: Click \"Copy to Clipboard\" button, paste into .env file</li> <li>Clear credentials: After copying, click \"Clear Credentials\" (security)</li> <li>Update .env: Save .env file with new values</li> <li>Restart Newt: Click \"Restart Newt Container\" button</li> <li>Verify status: Newt status changes to \"Ready\" (green tag)</li> </ol>"},{"location":"v2/frontend/pages/admin/pangolin-page/#viewing-status","title":"Viewing Status","text":"<ol> <li>Open page: Navigate to /app/tunnel</li> <li>Check configuration: Status card shows Configured/Healthy/Newt Container status</li> <li>Verify API URL: Copy URL if needed for external tools</li> <li>Check Org/Site IDs: Verify correct organization and site selected</li> <li>Monitor Newt: Check if container is Ready (green) or Stopped (red)</li> </ol>"},{"location":"v2/frontend/pages/admin/pangolin-page/#managing-resources","title":"Managing Resources","text":"<ol> <li>View resources: Scroll to \"Tunnel Resources\" card</li> <li>Check domains: Each row shows subdomain (e.g., <code>api.cmlite.org</code>)</li> <li>Verify SSL: Green \"Yes\" tag indicates SSL enabled</li> <li>Check active status: Green \"Active\" tag = resource enabled</li> <li>Review ports: Verify proxy port matches docker-compose.yml</li> <li>Edit resource: Click Edit button for a resource</li> <li>Modify settings: Change name, protocol, port, SSL, active, or block access</li> <li>Save changes: Click Update button in modal</li> <li>Verify update: Table refreshes with new values</li> </ol>"},{"location":"v2/frontend/pages/admin/pangolin-page/#syncing-resources","title":"Syncing Resources","text":"<ol> <li>Add new service: Update docker-compose.yml with new subdomain</li> <li>Click \"Sync Resources\": Button in table header</li> <li>Wait for sync: Loading state, ~2-5 seconds</li> <li>View results: Success message shows <code>{created} created, {skipped} skipped</code></li> <li>Check table: New resources appear in table</li> </ol>"},{"location":"v2/frontend/pages/admin/pangolin-page/#restarting-newt-container","title":"Restarting Newt Container","text":"<p>Scenario 1: After Credential Update 1. Update .env: Add PANGOLIN_SITE_ID and NEWT_* credentials 2. Click \"Restart Newt Container\" in setup wizard alert 3. Wait ~3 seconds: Container takes time to restart 4. Check status: \"Newt Container: Ready\" in status card</p> <p>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</p>"},{"location":"v2/frontend/pages/admin/pangolin-page/#deleting-resources","title":"Deleting Resources","text":"<ol> <li>Identify resource: Find resource to delete in table</li> <li>Click Delete button: Red icon button on right</li> <li>Read Popconfirm: \"Delete this resource?\"</li> <li>Confirm deletion: Click OK</li> <li>Resource removed: Table refreshes, resource no longer shown</li> </ol>"},{"location":"v2/frontend/pages/admin/pangolin-page/#editing-resource-configuration","title":"Editing Resource Configuration","text":"<ol> <li>Click Edit button: Opens Edit Resource modal</li> <li>Modify fields:</li> <li>Name: Display name for resource</li> <li>Protocol: <code>http</code> or <code>https</code></li> <li>Proxy Port: Internal container port (e.g., 4000 for API)</li> <li>Enable SSL: Checkbox for HTTPS</li> <li>Active: Checkbox to enable/disable resource</li> <li>Block Access: Checkbox to block public access (maintenance mode)</li> <li>Click Update: Saves changes to Pangolin API</li> <li>Close modal: Modal closes automatically on success</li> <li>Verify changes: Table shows updated values</li> </ol>"},{"location":"v2/frontend/pages/admin/pangolin-page/#component-breakdown","title":"Component Breakdown","text":""},{"location":"v2/frontend/pages/admin/pangolin-page/#status-card","title":"Status Card","text":"<pre><code><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</code></pre> <p>Responsive: 1 column on mobile, 2 columns on desktop</p>"},{"location":"v2/frontend/pages/admin/pangolin-page/#setup-form","title":"Setup Form","text":"<pre><code><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</code></pre>"},{"location":"v2/frontend/pages/admin/pangolin-page/#credential-display-alert","title":"Credential Display Alert","text":"<pre><code><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</code></pre>"},{"location":"v2/frontend/pages/admin/pangolin-page/#resource-table","title":"Resource Table","text":"<pre><code><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</code></pre>"},{"location":"v2/frontend/pages/admin/pangolin-page/#edit-resource-modal","title":"Edit Resource Modal","text":"<pre><code><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</code></pre>"},{"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":"<p>Status & Config: <pre><code>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</code></pre></p> <p>Setup Wizard State: <pre><code>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</code></pre></p> <p>Resource Management State: <pre><code>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</code></pre></p>"},{"location":"v2/frontend/pages/admin/pangolin-page/#data-fetching","title":"Data Fetching","text":"<p>Fetch Status + Config: <pre><code>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</code></pre></p> <p>Fetch Newt Status: <pre><code>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</code></pre></p> <p>Fetch Exit Nodes: <pre><code>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</code></pre></p>"},{"location":"v2/frontend/pages/admin/pangolin-page/#subnet-auto-suggestion","title":"Subnet Auto-Suggestion","text":"<p>Helper Function: <pre><code>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</code></pre></p> <p>Fetch and Suggest: <pre><code>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</code></pre></p>"},{"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":"<p>GET <code>/pangolin/status</code> - Check configuration and health <pre><code>const { data } = await api.get<PangolinStatus>('/pangolin/status');\n</code></pre></p> <p>Response: <pre><code>{\n \"configured\": true,\n \"healthy\": true,\n \"newtConfigured\": true\n}\n</code></pre></p> <p>GET <code>/pangolin/config</code> - Fetch Pangolin configuration <pre><code>const { data } = await api.get<PangolinConfig>('/pangolin/config');\n</code></pre></p> <p>Response: <pre><code>{\n \"pangolinApiUrl\": \"https://api.bnkserve.org/v1\",\n \"orgId\": \"org_abc123\",\n \"siteId\": \"site_xyz789\",\n \"domain\": \"cmlite.org\"\n}\n</code></pre></p> <p>GET <code>/pangolin/resources</code> - List all tunnel resources <pre><code>const { data } = await api.get<{ resources: PangolinResource[] }>('/pangolin/resources');\n</code></pre></p> <p>Response: <pre><code>{\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</code></pre></p> <p>POST <code>/pangolin/setup</code> - Create site and resources <pre><code>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</code></pre></p> <p>Response: <pre><code>{\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</code></pre></p> <p>POST <code>/pangolin/sync</code> - Sync resources from docker-compose.yml <pre><code>const { data } = await api.post<{ created: number; skipped: number; errors: number }>('/pangolin/sync');\n</code></pre></p> <p>Response: <pre><code>{\n \"created\": 3,\n \"skipped\": 5,\n \"errors\": 0\n}\n</code></pre></p> <p>PUT <code>/pangolin/resource/:resourceId</code> - Update resource <pre><code>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</code></pre></p> <p>DELETE <code>/pangolin/resource/:resourceId</code> - Delete resource <pre><code>await api.delete(`/pangolin/resource/${resourceId}`);\n</code></pre></p> <p>POST <code>/pangolin/newt-restart</code> - Restart Newt container <pre><code>await api.post('/pangolin/newt-restart');\n</code></pre></p> <p>GET <code>/pangolin/newt-status</code> - Get Newt container status <pre><code>const { data } = await api.get<PangolinNewtStatus>('/pangolin/newt-status');\n</code></pre></p> <p>Response: <pre><code>{\n \"containerRunning\": true,\n \"ready\": true\n}\n</code></pre></p> <p>GET <code>/pangolin/sites</code> - List all sites (for subnet suggestion) <pre><code>const { data } = await api.get<{ sites: PangolinSite[] }>('/pangolin/sites');\n</code></pre></p> <p>Response: <pre><code>{\n \"sites\": [\n {\n \"siteId\": \"site_1\",\n \"name\": \"changemaker-dev\",\n \"address\": \"100.90.128.2/24\"\n }\n ]\n}\n</code></pre></p> <p>GET <code>/pangolin/exit-nodes</code> - List available exit nodes <pre><code>const { data } = await api.get<{ exitNodes: PangolinExitNode[] }>('/pangolin/exit-nodes');\n</code></pre></p> <p>Response: <pre><code>{\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</code></pre></p>"},{"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":"<pre><code>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</code></pre>"},{"location":"v2/frontend/pages/admin/pangolin-page/#exit-node-auto-selection","title":"Exit Node Auto-Selection","text":"<pre><code>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</code></pre>"},{"location":"v2/frontend/pages/admin/pangolin-page/#text-sanitization-for-external-api-data","title":"Text Sanitization for External API Data","text":"<pre><code>const sanitizeText = (text: string | undefined): string => {\n if (!text) return '';\n return text.replace(/[<>'\"&]/g, (char) => {\n const escapeMap: Record<string, string> = {\n '<': '&lt;',\n '>': '&gt;',\n \"'\": '&#39;',\n '\"': '&quot;',\n '&': '&amp;',\n };\n return escapeMap[char] || char;\n });\n};\n\n// Usage in exit node options\n<Select.Option value={node.exitNodeId}>\n {sanitizeText(node.name)}\n {node.location && ` (${sanitizeText(node.location)})`}\n</Select.Option>\n</code></pre> <p>Why: Defense-in-depth against XSS if Pangolin API returns malicious data.</p>"},{"location":"v2/frontend/pages/admin/pangolin-page/#credential-showhide-pattern","title":"Credential Show/Hide Pattern","text":"<pre><code>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</code></pre> <p>Security: Credentials hidden by default, require user action to view.</p>"},{"location":"v2/frontend/pages/admin/pangolin-page/#delayed-status-check-after-restart","title":"Delayed Status Check After Restart","text":"<pre><code>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</code></pre> <p>Why 3 seconds: Newt container needs time to fully start before status check succeeds.</p>"},{"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":"<pre><code>const [statusRes, configRes] = await Promise.all([\n api.get('/pangolin/status'),\n api.get('/pangolin/config'),\n]);\n</code></pre> <p>Benefit: Loads status and config simultaneously (faster than sequential).</p>"},{"location":"v2/frontend/pages/admin/pangolin-page/#optional-newt-status-fetch","title":"Optional Newt Status Fetch","text":"<pre><code>const fetchNewtStatus = useCallback(async () => {\n if (!status?.newtConfigured) return; // Don't check if not configured\n // ... fetch logic\n}, [status?.newtConfigured]);\n</code></pre> <p>Benefit: Avoids unnecessary API call when Newt not configured.</p>"},{"location":"v2/frontend/pages/admin/pangolin-page/#graceful-exit-node-failure","title":"Graceful Exit Node Failure","text":"<pre><code>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</code></pre> <p>Benefit: Page works without exit nodes (self-hosted Pangolin doesn't use them).</p>"},{"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":"<pre><code><Descriptions column={{ xs: 1, sm: 2 }}>\n</code></pre> <p>Mobile (< 576px): 1 column (stacked) Desktop (\u2265 576px): 2 columns (side-by-side)</p>"},{"location":"v2/frontend/pages/admin/pangolin-page/#form-layout","title":"Form Layout","text":"<pre><code><Form layout=\"vertical\">\n</code></pre> <p>Vertical layout: Label above input (works well on all screen sizes).</p>"},{"location":"v2/frontend/pages/admin/pangolin-page/#accessibility","title":"Accessibility","text":""},{"location":"v2/frontend/pages/admin/pangolin-page/#button-labels","title":"Button Labels","text":"<p>All buttons have text labels (not icon-only): <pre><code><Button icon={<SyncOutlined />}>Sync Resources</Button>\n<Button icon={<SaveOutlined />}>Update</Button>\n</code></pre></p>"},{"location":"v2/frontend/pages/admin/pangolin-page/#form-tooltips","title":"Form Tooltips","text":"<pre><code><Form.Item\n tooltip=\"Network subnet for this site. Auto-suggested based on existing allocations.\"\n>\n</code></pre> <p>Provides context without cluttering label.</p>"},{"location":"v2/frontend/pages/admin/pangolin-page/#popconfirm-for-destructive-actions","title":"Popconfirm for Destructive Actions","text":"<pre><code><Popconfirm title=\"Delete this resource?\" onConfirm={handleDelete}>\n <Button danger />\n</Popconfirm>\n</code></pre> <p>Prevents accidental deletion.</p>"},{"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":"<p>Cause: Missing environment variables</p> <p>Solution: <pre><code># 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</code></pre></p>"},{"location":"v2/frontend/pages/admin/pangolin-page/#server-health-unreachable","title":"\"Server Health: Unreachable\"","text":"<p>Causes: 1. Pangolin API server down 2. Incorrect API URL 3. Network connectivity issue</p> <p>Debug: <pre><code># 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</code></pre></p>"},{"location":"v2/frontend/pages/admin/pangolin-page/#setup-fails-with-subnet-already-in-use","title":"Setup Fails with \"Subnet already in use\"","text":"<p>Cause: Suggested subnet conflicts with existing site</p> <p>Solution: 1. Manually override subnet in form (e.g., <code>100.90.128.5/24</code>) 2. Try again</p>"},{"location":"v2/frontend/pages/admin/pangolin-page/#newt-container-shows-stopped","title":"Newt Container Shows \"Stopped\"","text":"<p>Causes: 1. Container crashed 2. Missing NEWT_ID or NEWT_SECRET in .env 3. Wrong credentials</p> <p>Solutions: <pre><code># 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</code></pre></p>"},{"location":"v2/frontend/pages/admin/pangolin-page/#resources-not-syncing","title":"Resources Not Syncing","text":"<p>Symptoms: - Sync button does nothing - New resources not created</p> <p>Causes: 1. docker-compose.yml not updated 2. Subdomain naming mismatch 3. API error</p> <p>Debug: <pre><code># 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</code></pre></p>"},{"location":"v2/frontend/pages/admin/pangolin-page/#credentials-not-showing-after-setup","title":"Credentials Not Showing After Setup","text":"<p>Cause: <code>setupResult</code> state is null</p> <p>Debug: <pre><code>console.log('Setup result:', setupResult);\n</code></pre></p> <p>If null: Setup API call failed or returned unexpected format.</p>"},{"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":"<ul> <li>Pangolin Module - Service, client, routes</li> <li>Pangolin API Reference - Full endpoint documentation</li> </ul>"},{"location":"v2/frontend/pages/admin/pangolin-page/#features_1","title":"Features","text":"<ul> <li>Tunnel Management - Feature overview</li> <li>Newt Container - Container configuration</li> </ul>"},{"location":"v2/frontend/pages/admin/pangolin-page/#deployment","title":"Deployment","text":"<ul> <li>Pangolin Deployment - Production setup guide</li> <li>Environment Variables - All Pangolin env vars</li> </ul>"},{"location":"v2/frontend/pages/admin/pangolin-page/#troubleshooting_1","title":"Troubleshooting","text":"<ul> <li>Tunnel Issues - Connection troubleshooting</li> <li>Common Errors - General error resolution</li> </ul>"},{"location":"v2/frontend/pages/admin/pangolin-page/#user-guides","title":"User Guides","text":"<ul> <li>Admin Guide - Tunnel Management - Setup workflows</li> </ul>"},{"location":"v2/frontend/pages/admin/pangolin-page/#external-resources","title":"External Resources","text":"<ul> <li>Pangolin Integration API Documentation - Pangolin API reference</li> <li>Newt Container Documentation - Newt GitHub repo</li> </ul>"},{"location":"v2/frontend/pages/admin/representatives-page/","title":"RepresentativesPage","text":""},{"location":"v2/frontend/pages/admin/representatives-page/#overview","title":"Overview","text":"<p>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.</p> <p>Route: <code>/app/influence/representatives</code> Component: <code>admin/src/pages/RepresentativesPage.tsx</code> (387 lines) Auth Required: Yes (SUPER_ADMIN or INFLUENCE_ADMIN role recommended) Layout: AppLayout Backend Module: <code>api/src/modules/influence/representatives/</code></p>"},{"location":"v2/frontend/pages/admin/representatives-page/#screenshot","title":"Screenshot","text":"<p>[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.]</p>"},{"location":"v2/frontend/pages/admin/representatives-page/#features","title":"Features","text":"<ul> <li>Cache statistics dashboard \u2014 Real-time metrics (total reps, unique postal codes, average reps per postal)</li> <li>Postal code lookup \u2014 Fetch representatives from Represent API and populate cache</li> <li>Representative search \u2014 Filter by name, office, party, district (300ms debounce)</li> <li>Postal code filter \u2014 Show representatives for specific postal code only (300ms debounce)</li> <li>Government level filtering \u2014 Filter by Federal, Provincial, or Municipal</li> <li>Sortable table \u2014 Sort by name, office, level, party, district</li> <li>Detail drawer \u2014 View complete representative information with photo and links</li> <li>Cache clearing \u2014 Delete individual representatives or clear entire cache</li> <li>Color-coded tags \u2014 Visual indicators for government level and political party</li> <li>Pagination \u2014 Configurable page size (10, 25, 50, 100 per page)</li> <li>Responsive design \u2014 Mobile-friendly layout with stacked filters</li> <li>Auto-refresh stats \u2014 Statistics update after lookup/delete operations</li> </ul>"},{"location":"v2/frontend/pages/admin/representatives-page/#user-workflow","title":"User Workflow","text":""},{"location":"v2/frontend/pages/admin/representatives-page/#looking-up-representatives-for-a-postal-code","title":"Looking Up Representatives for a Postal Code","text":"<ol> <li>Navigate to <code>/app/influence/representatives</code></li> <li>Click \"Lookup New Postal Code\" button (top right)</li> <li>Modal appears: \"Lookup Representatives\"</li> <li>Enter a valid Canadian postal code (e.g., \"K1A 0A9\")</li> <li>Click \"Lookup\" button</li> <li>Wait for API request to Represent API</li> <li>Success scenarios:</li> <li>New representatives found: Success message \"Found 3 representatives for K1A 0A9 and cached them\"</li> <li>Already cached: Info message \"Representatives for K1A 0A9 are already cached\"</li> <li>No representatives found: Warning message \"No representatives found for K1A 0A9\"</li> <li>Table automatically refreshes to show newly cached representatives</li> <li>Statistics cards update to reflect new cache size</li> </ol>"},{"location":"v2/frontend/pages/admin/representatives-page/#searching-for-cached-representatives","title":"Searching for Cached Representatives","text":"<ol> <li>Locate \"Search Representatives\" input field (below statistics cards)</li> <li>Start typing search query (e.g., \"John Smith\")</li> <li>Search automatically triggers after 300ms pause (debounce)</li> <li>Table filters to show matching representatives</li> <li>Matches on: name, office title, political party, district name</li> <li>Clear search by clicking X icon or deleting text</li> </ol>"},{"location":"v2/frontend/pages/admin/representatives-page/#filtering-by-postal-code","title":"Filtering by Postal Code","text":"<ol> <li>Locate \"Postal Code Filter\" input field (next to search field)</li> <li>Start typing postal code (e.g., \"K1A\")</li> <li>Filter automatically triggers after 300ms pause (debounce)</li> <li>Table shows only representatives associated with that postal code</li> <li>Clear filter by clicking X icon or deleting text</li> <li>Can combine with search filter for more specific results</li> </ol>"},{"location":"v2/frontend/pages/admin/representatives-page/#viewing-representative-details","title":"Viewing Representative Details","text":"<ol> <li>Locate representative in table</li> <li>Click \"View\" button in Actions column</li> <li>Right-side drawer opens: \"Representative Details\"</li> <li>View information sections:</li> <li>Photo: Representative's portrait (if available)</li> <li>Basic Info: Name, political party, government level</li> <li>Office: Office title, district name</li> <li>Contact: Email, phone numbers (if available)</li> <li>Addresses: Office address, mailing address (if available)</li> <li>Social Media: Twitter, Facebook, website links (if available)</li> <li>Other Data: Custom fields from Represent API</li> <li>Click Close button or drawer overlay to dismiss</li> </ol>"},{"location":"v2/frontend/pages/admin/representatives-page/#deleting-cached-representatives","title":"Deleting Cached Representatives","text":""},{"location":"v2/frontend/pages/admin/representatives-page/#individual-deletion","title":"Individual Deletion","text":"<ol> <li>Locate representative in table</li> <li>Click \"Delete\" button in Actions column (red text)</li> <li>Confirmation modal appears: \"Are you sure you want to delete this representative from cache?\"</li> <li>Click \"Delete\" to confirm (or \"Cancel\" to abort)</li> <li>Success message: \"Representative deleted from cache\"</li> <li>Representative removed from table immediately</li> <li>Statistics cards update to reflect reduced cache size</li> </ol>"},{"location":"v2/frontend/pages/admin/representatives-page/#bulk-cache-clearing","title":"Bulk Cache Clearing","text":"<ol> <li>Click \"Clear All Cache\" button (top right, danger style)</li> <li>Confirmation modal appears: \"Are you sure you want to clear the entire representative cache? This will delete all 245 cached representatives.\"</li> <li>Enter confirmation phrase if prompted (optional safety measure)</li> <li>Click \"Clear Cache\" to confirm (or \"Cancel\" to abort)</li> <li>Loading indicator appears</li> <li>All cache entries deleted from database</li> <li>Success message: \"Cache cleared successfully. Deleted 245 representatives.\"</li> <li>Table refreshes to show empty state</li> <li>Statistics cards reset to zero</li> </ol>"},{"location":"v2/frontend/pages/admin/representatives-page/#sorting-the-table","title":"Sorting the Table","text":"<ol> <li>Identify sortable columns (Name, Office, Level, Party, District Name)</li> <li>Click column header to sort ascending (\u2191 arrow appears)</li> <li>Click again to sort descending (\u2193 arrow appears)</li> <li>Click third time to remove sorting (no arrow)</li> <li>Default sort: Name ascending</li> <li>Can combine with search/filter (sorted results only)</li> </ol>"},{"location":"v2/frontend/pages/admin/representatives-page/#component-breakdown","title":"Component Breakdown","text":""},{"location":"v2/frontend/pages/admin/representatives-page/#ant-design-components-used","title":"Ant Design Components Used","text":"<ul> <li>Typography.Title \u2014 Page heading (\"Representatives\")</li> <li>Typography.Text \u2014 Labels, descriptions, empty state text</li> <li>Row / Col \u2014 Grid layout for statistics cards and form fields</li> <li>Card \u2014 Statistics card containers</li> <li>Statistic \u2014 Formatted numeric statistics display</li> <li>Space \u2014 Button grouping (top-right buttons)</li> <li>Button \u2014 Primary actions (Lookup, Clear Cache), row actions (View, Delete)</li> <li>Input.Search \u2014 Representative search field with debounce</li> <li>Input \u2014 Postal code filter field</li> <li>Select \u2014 Government level filter dropdown</li> <li>Table \u2014 Main data table with sortable columns, pagination</li> <li>Tag \u2014 Color-coded government level and party indicators</li> <li>Modal \u2014 Confirmation dialogs (delete, clear cache), lookup modal</li> <li>Form \u2014 Postal code lookup form</li> <li>Form.Item \u2014 Form field wrapper with validation</li> <li>Drawer \u2014 Representative detail side panel</li> <li>Descriptions \u2014 Key-value pairs in detail drawer</li> <li>Descriptions.Item \u2014 Individual detail fields</li> <li>Image \u2014 Representative photo display</li> <li>Empty \u2014 Empty state when no representatives cached</li> <li>message \u2014 Toast notifications for success/error feedback</li> </ul>"},{"location":"v2/frontend/pages/admin/representatives-page/#table-structure","title":"Table Structure","text":"<pre><code>const columns: ColumnsType<Representative> = [\n {\n title: 'Name',\n dataIndex: 'name',\n key: 'name',\n sorter: (a, b) => a.name.localeCompare(b.name),\n width: 200,\n },\n {\n title: 'Office',\n dataIndex: 'officeTitle',\n key: 'officeTitle',\n sorter: (a, b) => (a.officeTitle || '').localeCompare(b.officeTitle || ''),\n width: 200,\n },\n {\n title: 'Level',\n dataIndex: 'level',\n key: 'level',\n width: 120,\n render: (level: string) => {\n const colorMap: Record<string, string> = {\n Federal: 'red',\n Provincial: 'blue',\n Municipal: 'green',\n };\n return <Tag color={colorMap[level] || 'default'}>{level}</Tag>;\n },\n sorter: (a, b) => a.level.localeCompare(b.level),\n },\n {\n title: 'Party',\n dataIndex: 'politicalParty',\n key: 'politicalParty',\n width: 150,\n render: (party: string | null) => {\n if (!party) return <Text type=\"secondary\">\u2014</Text>;\n return <Tag color=\"default\">{party}</Tag>;\n },\n sorter: (a, b) => (a.politicalParty || '').localeCompare(b.politicalParty || ''),\n },\n {\n title: 'District Name',\n dataIndex: 'districtName',\n key: 'districtName',\n sorter: (a, b) => (a.districtName || '').localeCompare(b.districtName || ''),\n },\n {\n title: 'Email',\n dataIndex: 'email',\n key: 'email',\n width: 200,\n render: (email: string | null) => {\n if (!email) return <Text type=\"secondary\">\u2014</Text>;\n return <a href={`mailto:${email}`}>{email}</a>;\n },\n },\n {\n title: 'Actions',\n key: 'actions',\n width: 140,\n fixed: 'right',\n render: (_: unknown, record: Representative) => (\n <Space size=\"small\">\n <Button\n size=\"small\"\n type=\"link\"\n icon={<EyeOutlined />}\n onClick={() => handleViewDetails(record)}\n >\n View\n </Button>\n <Button\n size=\"small\"\n type=\"link\"\n danger\n icon={<DeleteOutlined />}\n onClick={() => handleDeleteConfirm(record)}\n >\n Delete\n </Button>\n </Space>\n ),\n },\n];\n</code></pre> <p>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</p>"},{"location":"v2/frontend/pages/admin/representatives-page/#statistics-cards","title":"Statistics Cards","text":"<pre><code>{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</code></pre> <p>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</p>"},{"location":"v2/frontend/pages/admin/representatives-page/#detail-drawer","title":"Detail Drawer","text":"<pre><code><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</code></pre> <p>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 <pre>)"},{"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":"<pre><code>// 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</code></pre>\n<p>No Global State:</p>\n<p>This page does NOT use Zustand stores. Representative cache data is fetched directly from the API on mount and after mutations. This is appropriate because:\n- Representative cache is admin-only data (not needed globally)\n- Data changes infrequently (only on manual lookup/delete)\n- No need to share state between pages\n- Simpler architecture without store overhead</p>"},{"location":"v2/frontend/pages/admin/representatives-page/#debounced-search-pattern","title":"Debounced Search Pattern","text":"<pre><code>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</code></pre>\n<p>Why 300ms Debounce?</p>\n<ul>\n<li>Performance: Prevents API call on every keystroke</li>\n<li>User Experience: Long enough to avoid lag, short enough to feel responsive</li>\n<li>API Load: Reduces Represent API calls (external service rate limits)</li>\n<li>Two Separate Timers: Search and postal code filter have independent debounce timers</li>\n</ul>"},{"location":"v2/frontend/pages/admin/representatives-page/#usecallback-optimization","title":"useCallback Optimization","text":"<pre><code>const loadRepresentatives = useCallback(async () => {\n setLoading(true);\n try {\n const params: Record<string, unknown> = {\n page: pagination.current,\n limit: pagination.pageSize,\n };\n\n if (search) params.search = search;\n if (postalCodeFilter) params.postalCode = postalCodeFilter;\n if (levelFilter) params.level = levelFilter;\n\n const { data } = await api.get<{\n data: Representative[];\n pagination: { total: number };\n }>('/representatives', { params });\n\n setRepresentatives(data.data);\n setPagination((prev) => ({\n ...prev,\n total: data.pagination.total,\n }));\n } catch (error) {\n message.error('Failed to load representatives');\n } finally {\n setLoading(false);\n }\n}, [pagination.current, pagination.pageSize, search, postalCodeFilter, levelFilter]);\n\nconst loadStats = useCallback(async () => {\n try {\n const { data } = await api.get<CacheStats>('/representatives/stats');\n setStats(data);\n } catch (error) {\n message.error('Failed to load statistics');\n }\n}, []);\n\nuseEffect(() => {\n loadRepresentatives();\n}, [loadRepresentatives]);\n\nuseEffect(() => {\n loadStats();\n}, [loadStats]);\n</code></pre>\n<p>Why useCallback?</p>\n<ul>\n<li>Prevents infinite re-renders: Without useCallback, useEffect would create new function reference on every render, triggering effect again</li>\n<li>Optimized dependencies: Only re-creates function when filter/pagination values actually change</li>\n<li>Separate stats loading: Stats fetched independently, not affected by table filters</li>\n</ul>"},{"location":"v2/frontend/pages/admin/representatives-page/#api-integration","title":"API Integration","text":""},{"location":"v2/frontend/pages/admin/representatives-page/#endpoints-used","title":"Endpoints Used","text":"Method\nEndpoint\nPurpose\nAuth\n\n\n\n\nGET\n<code>/api/representatives</code>\nList cached representatives\nRequired\n\n\nGET\n<code>/api/representatives/stats</code>\nCache statistics\nRequired\n\n\nPOST\n<code>/api/representatives/lookup/:postalCode</code>\nLookup by postal code\nRequired\n\n\nDELETE\n<code>/api/representatives/:id</code>\nDelete single representative\nRequired\n\n\nDELETE\n<code>/api/representatives/cache</code>\nClear entire cache\nRequired"},{"location":"v2/frontend/pages/admin/representatives-page/#load-representatives-paginated-with-filters","title":"Load Representatives (Paginated with Filters)","text":"<p>Request:</p>\n<pre><code>const params: Record<string, unknown> = {\n page: 1,\n limit: 10,\n search: 'John Smith', // Optional: search query\n postalCode: 'K1A 0A9', // Optional: postal code filter\n level: 'Federal', // Optional: government level filter\n};\n\nconst { data } = await api.get<{\n data: Representative[];\n pagination: { total: number; page: number; limit: number };\n}>('/representatives', { params });\n</code></pre>\n<p>Query Parameters:\n- <code>page</code> (number, required): Page number (1-indexed)\n- <code>limit</code> (number, required): Items per page (10, 25, 50, or 100)\n- <code>search</code> (string, optional): Search query (matches name, office, party, district)\n- <code>postalCode</code> (string, optional): Filter by postal code\n- <code>level</code> (string, optional): Filter by government level (Federal, Provincial, Municipal)</p>\n<p>Response (200 OK):</p>\n<pre><code>{\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</code></pre>\n<p>Response Fields:</p>\n<p>Core fields (always present):\n- <code>id</code> (string): Unique representative identifier (prefixed with \"rep_\")\n- <code>name</code> (string): Full name\n- <code>level</code> (string): Government level (Federal, Provincial, or Municipal)\n- <code>postalCode</code> (string): Associated postal code\n- <code>createdAt</code> (ISO 8601): Cache entry creation timestamp\n- <code>updatedAt</code> (ISO 8601): Cache entry last update timestamp</p>\n<p>Optional fields (may be null):\n- <code>officeTitle</code> (string | null): Job title\n- <code>politicalParty</code> (string | null): Political party affiliation\n- <code>districtName</code> (string | null): Electoral district name\n- <code>email</code> (string | null): Contact email\n- <code>phone</code> (string | null): Contact phone\n- <code>fax</code> (string | null): Fax number\n- <code>photoUrl</code> (string | null): Portrait image URL\n- <code>personalUrl</code> (string | null): Personal/campaign website\n- <code>officeAddress</code> (string | null): Physical office location\n- <code>mailingAddress</code> (string | null): Mailing address\n- <code>socialMedia</code> (object | null): Social media links (twitter, facebook)\n- <code>otherData</code> (object): Additional custom fields from Represent API</p>"},{"location":"v2/frontend/pages/admin/representatives-page/#load-cache-statistics","title":"Load Cache Statistics","text":"<p>Request:</p>\n<pre><code>const { data } = await api.get<CacheStats>('/representatives/stats');\n</code></pre>\n<p>Response (200 OK):</p>\n<pre><code>{\n \"totalRepresentatives\": 245,\n \"uniquePostalCodes\": 89,\n \"avgRepsPerPostal\": 2.8,\n \"breakdown\": {\n \"Federal\": 89,\n \"Provincial\": 89,\n \"Municipal\": 67\n }\n}\n</code></pre>\n<p>Response Fields:\n- <code>totalRepresentatives</code> (number): Total cached representatives across all postal codes\n- <code>uniquePostalCodes</code> (number): Number of distinct postal codes in cache\n- <code>avgRepsPerPostal</code> (number): Average representatives per postal code (decimal)\n- <code>breakdown</code> (object): Count by government level (Federal, Provincial, Municipal)</p>\n<p>Statistics Calculation:</p>\n<pre><code>// 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</code></pre>"},{"location":"v2/frontend/pages/admin/representatives-page/#lookup-representatives-by-postal-code","title":"Lookup Representatives by Postal Code","text":"<p>Request:</p>\n<pre><code>const postalCode = 'K1A 0A9';\nconst { data } = await api.post<{\n message: string;\n count: number;\n representatives: Representative[];\n}>(`/representatives/lookup/${postalCode}`);\n</code></pre>\n<p>URL Parameter:\n- <code>postalCode</code> (string): Canadian postal code (format: \"A1A 1A1\" or \"A1A1A1\")</p>\n<p>Response (200 OK) - New Representatives Found:</p>\n<pre><code>{\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</code></pre>\n<p>Response (200 OK) - Already Cached:</p>\n<pre><code>{\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</code></pre>\n<p>Response (200 OK) - No Representatives Found:</p>\n<pre><code>{\n \"message\": \"No representatives found for K1A 0A9\",\n \"count\": 0,\n \"representatives\": []\n}\n</code></pre>\n<p>Error Response (400 Bad Request) - Invalid Postal Code:</p>\n<pre><code>{\n \"error\": \"Validation Error\",\n \"details\": [\n {\n \"field\": \"postalCode\",\n \"message\": \"Invalid postal code format. Expected format: A1A 1A1\"\n }\n ]\n}\n</code></pre>\n<p>Error Response (503 Service Unavailable) - Represent API Down:</p>\n<pre><code>{\n \"error\": \"External service unavailable\",\n \"message\": \"Represent API is temporarily unavailable. Please try again later.\"\n}\n</code></pre>\n<p>Backend Workflow:</p>\n<pre><code>// 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</code></pre>"},{"location":"v2/frontend/pages/admin/representatives-page/#delete-representative","title":"Delete Representative","text":"<p>Request:</p>\n<pre><code>const repId = 'rep_abc123';\nawait api.delete(`/representatives/${repId}`);\n</code></pre>\n<p>URL Parameter:\n- <code>id</code> (string): Representative ID to delete</p>\n<p>Response (200 OK):</p>\n<pre><code>{\n \"message\": \"Representative deleted from cache\"\n}\n</code></pre>\n<p>Error Response (404 Not Found):</p>\n<pre><code>{\n \"error\": \"Not Found\",\n \"message\": \"Representative not found with ID: rep_abc123\"\n}\n</code></pre>"},{"location":"v2/frontend/pages/admin/representatives-page/#clear-entire-cache","title":"Clear Entire Cache","text":"<p>Request:</p>\n<pre><code>const { data } = await api.delete<{ message: string; count: number }>('/representatives/cache');\n</code></pre>\n<p>Response (200 OK):</p>\n<pre><code>{\n \"message\": \"Cache cleared successfully\",\n \"count\": 245\n}\n</code></pre>\n<p>Response Fields:\n- <code>message</code> (string): Confirmation message\n- <code>count</code> (number): Number of representatives deleted</p>\n<p>Backend Implementation:</p>\n<pre><code>const count = await prisma.representative.count();\nawait prisma.representative.deleteMany({});\nreturn { message: 'Cache cleared successfully', count };\n</code></pre>"},{"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":"<pre><code>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</code></pre>\n<p>Key Steps:\n1. Normalize postal code (uppercase, single space)\n2. URL-encode postal code for API request\n3. Handle three success scenarios (new, cached, not found)\n4. Show appropriate message type (success, info, warning)\n5. Refresh both table and statistics after successful lookup\n6. Close modal and reset form on success\n7. Handle specific error codes (503, 400)\n8. Always set loading state in finally block</p>"},{"location":"v2/frontend/pages/admin/representatives-page/#delete-with-confirmation","title":"Delete with Confirmation","text":"<pre><code>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</code></pre>\n<p>Confirmation Pattern:\n- Uses Ant Design <code>Modal.confirm</code> static method (no state needed)\n- Shows representative name in confirmation text for clarity\n- Async <code>onOk</code> handler performs delete and refresh\n- Refreshes both table and stats to keep UI in sync\n- Error handling within <code>onOk</code> (doesn't prevent modal close)</p>"},{"location":"v2/frontend/pages/admin/representatives-page/#clear-all-cache-with-confirmation","title":"Clear All Cache with Confirmation","text":"<pre><code>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</code></pre>\n<p>Enhanced Confirmation:\n- Dynamically includes total count in confirmation message (if stats loaded)\n- Shows exact number of representatives that will be deleted\n- Success message includes deleted count for verification\n- Uses danger button styling to emphasize destructive action</p>"},{"location":"v2/frontend/pages/admin/representatives-page/#color-coded-government-level-tags","title":"Color-Coded Government Level Tags","text":"<pre><code>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</code></pre>\n<p>Color Mapping:\n- Federal: Red (highest level of government)\n- Provincial: Blue (middle level)\n- Municipal: Green (local level)\n- Default: Gray (unknown/other levels)</p>"},{"location":"v2/frontend/pages/admin/representatives-page/#debounced-filter-implementation","title":"Debounced Filter Implementation","text":"<pre><code>// 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</code></pre>\n<p>Two Independent Debounce Timers:\n- Separate refs: <code>searchTimerRef</code> and <code>postalTimerRef</code> 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</p>"},{"location":"v2/frontend/pages/admin/representatives-page/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/frontend/pages/admin/representatives-page/#efficient-pagination","title":"Efficient Pagination","text":"<p>The page uses server-side pagination to handle large cache datasets efficiently:</p>\n<pre><code>const { data } = await api.get('/representatives', {\n params: {\n page: pagination.current,\n limit: pagination.pageSize,\n search,\n postalCodeFilter,\n levelFilter,\n },\n});\n</code></pre>\n<p>Benefits:\n- Reduced payload: Only fetches current page (10-100 items) instead of all 245+\n- Fast rendering: Table renders 10-100 rows instead of potentially thousands\n- Scalable: Works efficiently with cache sizes from 10 to 10,000+ representatives\n- Combined filtering: Backend applies filters before pagination, returning only relevant results</p>"},{"location":"v2/frontend/pages/admin/representatives-page/#debounced-search-300ms","title":"Debounced Search (300ms)","text":"<p>Prevents API spam during typing:</p>\n<pre><code>searchTimerRef.current = setTimeout(() => {\n setSearch(value);\n}, 300);\n</code></pre>\n<p>Performance Impact:\n- Without debounce: Typing \"John Smith\" (10 characters) = 10 API calls\n- With 300ms debounce: Typing \"John Smith\" = 1 API call (after 300ms pause)\n- Network savings: 90% reduction in API requests for typical typing speed\n- Backend load: Reduces database queries and Represent API calls</p>"},{"location":"v2/frontend/pages/admin/representatives-page/#usecallback-for-fetch-functions","title":"useCallback for Fetch Functions","text":"<p>Prevents unnecessary re-renders:</p>\n<pre><code>const loadRepresentatives = useCallback(async () => {\n // ... fetch logic\n}, [pagination.current, pagination.pageSize, search, postalCodeFilter, levelFilter]);\n</code></pre>\n<p>Why This Matters:\n- Without useCallback: Function reference changes every render, triggering useEffect infinitely\n- With useCallback: Function reference only changes when dependencies change\n- Result: useEffect runs only when filters/pagination actually change, not on every render</p>"},{"location":"v2/frontend/pages/admin/representatives-page/#statistics-caching","title":"Statistics Caching","text":"<p>Statistics are loaded separately and don't re-fetch on table filter changes:</p>\n<pre><code>const loadStats = useCallback(async () => {\n const { data } = await api.get<CacheStats>('/representatives/stats');\n setStats(data);\n}, []);\n\nuseEffect(() => {\n loadStats();\n}, [loadStats]); // Only runs on mount\n</code></pre>\n<p>Benefits:\n- Independent updates: Stats only refresh after lookup/delete operations, not on search/filter\n- Reduced API calls: Stats don't need to be recalculated for every table filter\n- Better UX: Statistics cards remain stable while user searches/filters table</p>"},{"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":"<p>The page adapts gracefully to mobile viewports:</p>\n<p>Statistics Cards:\n<pre><code><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</code></pre></p>\n<p>Filter Inputs:\n<pre><code><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</code></pre></p>\n<p>Responsive Grid Breakpoints:\n- xs (mobile, <576px): Stacked layout, full-width cards and inputs\n- sm (tablet, \u2265576px): 3-column statistics cards\n- md (desktop, \u2265768px): Side-by-side filter inputs\n- lg+ (large desktop, \u2265992px): Full table width with all columns visible</p>"},{"location":"v2/frontend/pages/admin/representatives-page/#table-column-responsiveness","title":"Table Column Responsiveness","text":"<p>Columns use <code>responsive</code> prop to hide on mobile:</p>\n<pre><code>{\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</code></pre>\n<p>Mobile Table (xs, sm):\n- Name (visible)\n- Level (visible with color tags)\n- Actions (visible)</p>\n<p>Desktop Table (md+):\n- Name + Office + Level + Party + District + Email + Actions (all visible)</p>"},{"location":"v2/frontend/pages/admin/representatives-page/#drawer-width","title":"Drawer Width","text":"<p>Detail drawer adapts to screen size:</p>\n<pre><code><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</code></pre>\n<p>Behavior:\n- Desktop (\u2265768px): 600px slide-in panel from right\n- Mobile (<768px): Full-width slide-in panel (automatically handled by Ant Design)</p>"},{"location":"v2/frontend/pages/admin/representatives-page/#accessibility","title":"Accessibility","text":""},{"location":"v2/frontend/pages/admin/representatives-page/#keyboard-navigation","title":"Keyboard Navigation","text":"<p>All interactive elements are keyboard-accessible:</p>\n<p>Table Navigation:\n- Tab: Move between action buttons (View, Delete)\n- Enter/Space: Activate focused button\n- Arrow Keys: Navigate table rows (Ant Design built-in)</p>\n<p>Form Fields:\n- Tab: Move between search input, postal code filter, level dropdown\n- Escape: Clear input fields (when using allowClear)\n- Enter: Submit lookup form</p>\n<p>Modal/Drawer:\n- Escape: Close modal or drawer\n- Tab: Cycle through focusable elements inside modal/drawer\n- Enter: Confirm action (in confirmation modals)</p>"},{"location":"v2/frontend/pages/admin/representatives-page/#screen-reader-support","title":"Screen Reader Support","text":"<p>The page provides semantic HTML and ARIA labels:</p>\n<p>Statistics Cards:\n<pre><code><Statistic\n title=\"Cached Representatives\" // Read by screen readers\n value={stats.totalRepresentatives}\n prefix={<TeamOutlined />}\n/>\n</code></pre></p>\n<p>Action Buttons:\n<pre><code><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</code></pre></p>\n<p>Table Sorting:\n<pre><code>{\n title: 'Name',\n sorter: (a, b) => a.name.localeCompare(b.name),\n // Ant Design automatically adds aria-sort=\"ascending|descending|none\"\n}\n</code></pre></p>"},{"location":"v2/frontend/pages/admin/representatives-page/#color-contrast","title":"Color Contrast","text":"<p>All color-coded elements meet WCAG AA standards:</p>\n<p>Government Level Tags:\n- Federal (red): <code>#f5222d</code> on white background = 4.5:1 contrast ratio\n- Provincial (blue): <code>#1890ff</code> on white background = 4.5:1 contrast ratio\n- Municipal (green): <code>#52c41a</code> on white background = 4.5:1 contrast ratio</p>\n<p>Text Colors:\n- Primary text: <code>rgba(0, 0, 0, 0.85)</code> = 13.6:1 contrast ratio\n- Secondary text: <code>rgba(0, 0, 0, 0.45)</code> = 7.0:1 contrast ratio (used for \"\u2014\" null values)</p>"},{"location":"v2/frontend/pages/admin/representatives-page/#focus-indicators","title":"Focus Indicators","text":"<p>All interactive elements have visible focus states:</p>\n<p>Buttons:\n<pre><code>.ant-btn:focus {\n outline: 2px solid #1890ff;\n outline-offset: 2px;\n}\n</code></pre></p>\n<p>Input Fields:\n<pre><code>.ant-input:focus {\n border-color: #40a9ff;\n box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);\n}\n</code></pre></p>"},{"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":"<p>Problem: Page shows empty state or loading spinner indefinitely.</p>\n<p>Diagnosis:</p>\n<p>Check browser console for errors:</p>\n<pre><code>// Console error\nGET https://api.cmlite.org/representatives 401 Unauthorized\n</code></pre>\n<p>Possible Causes:</p>\n<ol>\n<li>Not authenticated:</li>\n<li>Check if JWT access token is expired</li>\n<li>\n<p>Check if user has required role (SUPER_ADMIN or INFLUENCE_ADMIN)</p>\n</li>\n<li>\n<p>Backend API down:</p>\n</li>\n<li>Verify API container is running: <code>docker compose ps api</code></li>\n<li>\n<p>Check API logs: <code>docker compose logs api</code></p>\n</li>\n<li>\n<p>Database connection issue:</p>\n</li>\n<li>Verify PostgreSQL is running: <code>docker compose ps v2-postgres</code></li>\n<li>Check database connection: <code>docker compose exec api npx prisma db push</code></li>\n</ol>\n<p>Solution:</p>\n<ol>\n<li>Refresh page to trigger token refresh</li>\n<li>Log out and log back in to get new token</li>\n<li>Verify backend services are running: <code>docker compose up -d api v2-postgres</code></li>\n<li>Check API logs for specific error: <code>docker compose logs -f api | grep representatives</code></li>\n</ol>"},{"location":"v2/frontend/pages/admin/representatives-page/#postal-code-lookup-fails","title":"Postal Code Lookup Fails","text":"<p>Problem: Click \"Lookup New Postal Code\", enter postal code, get error: \"Failed to lookup representatives\".</p>\n<p>Diagnosis:</p>\n<p>Check network tab in browser DevTools:</p>\n<pre><code>// 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</code></pre>\n<p>Possible Causes:</p>\n<ol>\n<li>Represent API down:</li>\n<li>represent.opennorth.ca is temporarily unavailable (503)</li>\n<li>\n<p>Network firewall blocking external API requests</p>\n</li>\n<li>\n<p>Invalid postal code format:</p>\n</li>\n<li>Missing space in postal code (should be \"K1A 0A9\", not \"K1A0A9\")</li>\n<li>\n<p>Non-Canadian postal code entered</p>\n</li>\n<li>\n<p>Rate limit exceeded:</p>\n</li>\n<li>Too many requests to Represent API from same IP</li>\n<li>Represent API rate limit: 60 requests/minute/IP</li>\n</ol>\n<p>Solution:</p>\n<ol>\n<li>For Represent API downtime:</li>\n<li>Wait 5-10 minutes and retry</li>\n<li>Check Represent API status: https://represent.opennorth.ca/postcodes/K1A0A9/</li>\n<li>\n<p>Use cached representatives if available (search for existing postal code)</p>\n</li>\n<li>\n<p>For invalid postal code:</p>\n</li>\n<li>Ensure format is \"A1A 1A1\" with space (e.g., \"K1A 0A9\")</li>\n<li>Use Canadian postal codes only (Represent API only covers Canada)</li>\n<li>\n<p>Try a known-valid postal code: \"K1A 0A9\" (Ottawa, Parliament Hill)</p>\n</li>\n<li>\n<p>For rate limits:</p>\n</li>\n<li>Wait 1 minute before retrying</li>\n<li>Reduce lookup frequency (don't spam the button)</li>\n<li>Check existing cache first (use search/filter)</li>\n</ol>"},{"location":"v2/frontend/pages/admin/representatives-page/#delete-button-not-working","title":"Delete Button Not Working","text":"<p>Problem: Click \"Delete\" button, confirmation modal appears, click \"Delete\" again, but representative remains in table.</p>\n<p>Diagnosis:</p>\n<p>Check network tab:</p>\n<pre><code>// Response from DELETE /representatives/rep_abc123\n{\n \"error\": \"Forbidden\",\n \"message\": \"Insufficient permissions. Required role: SUPER_ADMIN or INFLUENCE_ADMIN\"\n}\n</code></pre>\n<p>Possible Causes:</p>\n<ol>\n<li>Insufficient permissions:</li>\n<li>User role is USER (not INFLUENCE_ADMIN or SUPER_ADMIN)</li>\n<li>\n<p>JWT token has expired and refresh failed</p>\n</li>\n<li>\n<p>Representative already deleted:</p>\n</li>\n<li>Another admin deleted the representative concurrently</li>\n<li>\n<p>Table hasn't refreshed to reflect deletion</p>\n</li>\n<li>\n<p>Database constraint violation:</p>\n</li>\n<li>Representative is referenced by campaign emails (foreign key constraint)</li>\n<li>Cannot delete due to active references</li>\n</ol>\n<p>Solution:</p>\n<ol>\n<li>For permission issues:</li>\n<li>Contact system administrator to grant INFLUENCE_ADMIN role</li>\n<li>Log out and log back in to refresh permissions</li>\n<li>\n<p>Check user role in profile dropdown (top-right corner)</p>\n</li>\n<li>\n<p>For concurrent deletion:</p>\n</li>\n<li>Refresh page to see current cache state</li>\n<li>\n<p>If representative is gone, deletion succeeded (UI just didn't update)</p>\n</li>\n<li>\n<p>For constraint violations:</p>\n</li>\n<li>Delete dependent records first (campaign emails referencing this representative)</li>\n<li>Or use \"Clear All Cache\" button to delete everything at once (cascading delete)</li>\n</ol>"},{"location":"v2/frontend/pages/admin/representatives-page/#search-not-working","title":"Search Not Working","text":"<p>Problem: Type search query in \"Search Representatives\" field, but table doesn't filter.</p>\n<p>Diagnosis:</p>\n<p>Check if debounce timer is working:</p>\n<pre><code>// Wait 300ms after typing\n// If table still doesn't update, check console for errors\n</code></pre>\n<p>Possible Causes:</p>\n<ol>\n<li>Typing too fast:</li>\n<li>Debounce timer resets on every keystroke</li>\n<li>\n<p>Must wait 300ms after last keystroke</p>\n</li>\n<li>\n<p>Case sensitivity:</p>\n</li>\n<li>Search is case-insensitive on backend, but special characters may cause issues</li>\n<li>\n<p>Accent characters (\u00e9, \u00e0, \u00f1) may not match correctly</p>\n</li>\n<li>\n<p>Search scope confusion:</p>\n</li>\n<li>Search only matches: name, officeTitle, politicalParty, districtName</li>\n<li>Does NOT search: email, phone, addresses, otherData</li>\n</ol>\n<p>Solution:</p>\n<ol>\n<li>For typing speed:</li>\n<li>Pause typing for 300ms (about half a second)</li>\n<li>\n<p>Watch for table loading spinner to confirm search triggered</p>\n</li>\n<li>\n<p>For special characters:</p>\n</li>\n<li>Remove accents (e.g., search \"Montreal\" instead of \"Montr\u00e9al\")</li>\n<li>\n<p>Use partial matches (e.g., \"Smith\" instead of \"O'Smith\")</p>\n</li>\n<li>\n<p>For search scope:</p>\n</li>\n<li>Search by name: \"John Smith\"</li>\n<li>Search by party: \"Liberal\"</li>\n<li>Search by district: \"Ottawa Centre\"</li>\n<li>Use postal code filter for location-based filtering (separate input field)</li>\n</ol>"},{"location":"v2/frontend/pages/admin/representatives-page/#statistics-not-updating","title":"Statistics Not Updating","text":"<p>Problem: Add new representatives via lookup, but statistics cards still show old counts.</p>\n<p>Diagnosis:</p>\n<p>Check if <code>loadStats()</code> is called after lookup:</p>\n<pre><code>// 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</code></pre>\n<p>Possible Causes:</p>\n<ol>\n<li>Frontend bug:</li>\n<li><code>loadStats()</code> not called after successful lookup</li>\n<li>\n<p>Promise.all([loadRepresentatives(), loadStats()]) failed silently</p>\n</li>\n<li>\n<p>Backend calculation error:</p>\n</li>\n<li>Statistics endpoint returning cached/stale data</li>\n<li>\n<p>Database aggregation query not reflecting new records</p>\n</li>\n<li>\n<p>Cache invalidation:</p>\n</li>\n<li>Backend caching statistics for performance</li>\n<li>Cache not invalidated after lookup/delete operations</li>\n</ol>\n<p>Solution:</p>\n<ol>\n<li>Manual refresh:</li>\n<li>Refresh entire page (F5 or Ctrl+R)</li>\n<li>\n<p>Statistics should update to reflect current cache state</p>\n</li>\n<li>\n<p>Check backend logs:</p>\n</li>\n<li>Look for statistics calculation errors: <code>docker compose logs api | grep stats</code></li>\n<li>\n<p>Verify database connection during stats calculation</p>\n</li>\n<li>\n<p>Developer fix (if bug):</p>\n</li>\n<li>Ensure <code>loadStats()</code> is called after lookup/delete:\n <pre><code>await Promise.all([loadRepresentatives(), loadStats()]);\n</code></pre></li>\n<li>Remove backend statistics caching (if implemented)</li>\n</ol>"},{"location":"v2/frontend/pages/admin/representatives-page/#related-documentation","title":"Related Documentation","text":"<ul>\n<li>Influence Module Overview \u2014 Advocacy campaigns feature set</li>\n<li>Represent API Integration \u2014 Backend representative service</li>\n<li>Representative Cache API \u2014 API endpoint reference</li>\n<li>Campaigns Backend Module \u2014 Campaign system using representatives</li>\n<li>Postal Codes Backend Module \u2014 Postal code cache system</li>\n<li>CampaignsPage \u2014 Campaign management page (uses representatives)</li>\n<li>Public Campaign Page \u2014 Public campaign page (postal code lookup)</li>\n<li>User Guide: Campaign Management \u2014 Campaign manager workflow</li>\n<li>Troubleshooting: Represent API \u2014 Represent API issues</li>\n<li>External Services \u2014 Represent API architecture integration</li>\n</ul>"},{"location":"v2/frontend/pages/admin/responses-page/","title":"ResponsesPage","text":""},{"location":"v2/frontend/pages/admin/responses-page/#overview","title":"Overview","text":"<p>The ResponsesPage provides moderation and management for public campaign responses submitted through the response wall feature. Administrators can review user submissions, approve or reject responses for public display, resend verification emails, and view detailed response information. Features include advanced filtering by status and campaign, search functionality, and clickable rows for detailed views.</p> <p>Route: <code>/app/influence/responses</code> Component: <code>admin/src/pages/ResponsesPage.tsx</code> (400 lines) Auth Required: Yes (SUPER_ADMIN, INFLUENCE_ADMIN roles) Layout: AppLayout</p>"},{"location":"v2/frontend/pages/admin/responses-page/#screenshot","title":"Screenshot","text":"<p>[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.]</p>"},{"location":"v2/frontend/pages/admin/responses-page/#features","title":"Features","text":"<ul> <li>Full response moderation \u2014 Approve, reject, or delete public responses</li> <li>Verification management \u2014 Resend verification emails for unverified responses</li> <li>Advanced filtering \u2014 Filter by status (PENDING/APPROVED/REJECTED) and campaign</li> <li>Search functionality \u2014 400ms debounced search across response text</li> <li>Government level tags \u2014 Color-coded tags for FEDERAL, PROVINCIAL, MUNICIPAL, SCHOOL_BOARD</li> <li>Response type tracking \u2014 EMAIL (sent via SMTP) vs PHONE (called representative)</li> <li>Upvote display \u2014 Show public upvote count for each response</li> <li>Verification badges \u2014 SafetyCertificateOutlined icon for verified email addresses</li> <li>Clickable rows \u2014 Click any row to open detail drawer</li> <li>Detail drawer \u2014 Comprehensive response view with all metadata</li> <li>Action buttons \u2014 Conditional rendering based on status (Approve hidden if already approved)</li> <li>Responsive table \u2014 Columns hide on smaller screens (Verified, Upvotes: md+, Submitted: sm+)</li> </ul>"},{"location":"v2/frontend/pages/admin/responses-page/#user-workflow","title":"User Workflow","text":""},{"location":"v2/frontend/pages/admin/responses-page/#viewing-responses-list","title":"Viewing Responses List","text":"<ol> <li>Navigate to <code>/app/influence/responses</code></li> <li>Page loads first 20 responses (paginated)</li> <li>View response details:</li> <li>Representative name</li> <li>Government level tag (colored)</li> <li>Response type (EMAIL/PHONE)</li> <li>Campaign title</li> <li>Status tag (PENDING: yellow, APPROVED: green, REJECTED: red)</li> <li>Verified checkmark icon (if email verified)</li> <li>Upvote count</li> <li>Submitted date</li> <li>Action buttons</li> <li>Click any row to open detail drawer</li> </ol>"},{"location":"v2/frontend/pages/admin/responses-page/#filtering-responses","title":"Filtering Responses","text":"<ol> <li>Status filter (dropdown):</li> <li>Select PENDING, APPROVED, or REJECTED</li> <li>Clear to show all statuses</li> <li>Campaign filter (dropdown with search):</li> <li>Select campaign from list (up to 100 campaigns)</li> <li>Type to search campaign titles</li> <li>Clear to show all campaigns</li> <li>Search bar:</li> <li>Type keywords to search response text</li> <li>400ms debounce (waits for typing pause)</li> <li>Search resets pagination to page 1</li> <li>Filters combine (AND logic): status + campaign + search</li> </ol>"},{"location":"v2/frontend/pages/admin/responses-page/#approving-a-response","title":"Approving a Response","text":"<ol> <li>Locate PENDING or REJECTED response in table</li> <li>Click \"Approve\" button (green, CheckCircleOutlined icon)</li> <li>Button shows loading spinner</li> <li>Success message: \"Response approved\"</li> <li>Table refreshes with updated status</li> <li>Effect:</li> <li>Response status changes to APPROVED</li> <li>Response now visible on public response wall</li> <li>User receives confirmation email (if email verified)</li> <li>Approve button hidden if response already APPROVED</li> </ol>"},{"location":"v2/frontend/pages/admin/responses-page/#rejecting-a-response","title":"Rejecting a Response","text":"<ol> <li>Locate PENDING or APPROVED response in table</li> <li>Click \"Reject\" button (red, CloseCircleOutlined icon)</li> <li>Button shows loading spinner</li> <li>Success message: \"Response rejected\"</li> <li>Table refreshes with updated status</li> <li>Effect:</li> <li>Response status changes to REJECTED</li> <li>Response hidden from public response wall</li> <li>User does NOT receive notification (silent rejection)</li> <li>Reject button hidden if response already REJECTED</li> </ol>"},{"location":"v2/frontend/pages/admin/responses-page/#resending-verification-email","title":"Resending Verification Email","text":"<ol> <li>Locate response with <code>representativeEmail</code> (not null)</li> <li>Click \"Verify\" button (MailOutlined icon)</li> <li>Button shows loading spinner</li> <li>Success message: \"Verification email sent\"</li> <li>Email sent to user with verification link</li> <li>User clicks link \u2192 <code>isVerified</code> set to true</li> <li>Verified responses show SafetyCertificateOutlined icon</li> </ol> <p>Verification flow: 1. User submits response on public page 2. System sends verification email to <code>submittedByEmail</code> 3. User clicks verification link in email 4. Backend marks response as verified (<code>isVerified: true</code>) 5. Verified responses show checkmark icon in table</p>"},{"location":"v2/frontend/pages/admin/responses-page/#deleting-a-response","title":"Deleting a Response","text":"<ol> <li>Locate response in table</li> <li>Click Delete icon button (DeleteOutlined, red)</li> <li>Popconfirm: \"Delete this response?\"</li> <li>Click \"OK\" to confirm</li> <li>Button shows loading spinner</li> <li>Success message: \"Response deleted\"</li> <li>Table refreshes (response disappears)</li> <li>Cascade behavior: Response record permanently deleted from database</li> </ol> <p>Warning: Deletion is permanent. No soft delete. Upvotes also deleted (cascade).</p>"},{"location":"v2/frontend/pages/admin/responses-page/#viewing-response-details","title":"Viewing Response Details","text":"<ol> <li>Click any row in table</li> <li>Detail drawer opens on right side (520px width)</li> <li>Drawer content (Descriptions component with bordered layout):</li> <li>Representative: Name + Title (if present)</li> <li>Level: Government level tag (colored)</li> <li>Type: Response type tag (EMAIL/PHONE)</li> <li>Campaign: Campaign title</li> <li>Status: Status tag (colored)</li> <li>Verified: Yes/No + verified by + verified date (if verified)</li> <li>Upvotes: Count</li> <li>Response Text: Full response text (scrollable, max 300px height, pre-wrap)</li> <li>User Comment: Additional comment (if present)</li> <li>Submitted By: Name + Email OR \"Anonymous\" (if anonymous)</li> <li>Submitted: Full timestamp (MMM D, YYYY h:mm A)</li> <li>Click \"X\" or outside drawer to close</li> </ol>"},{"location":"v2/frontend/pages/admin/responses-page/#refreshing-data","title":"Refreshing Data","text":"<ol> <li>Click \"Refresh\" button in page header</li> <li>Table reloads with current filters applied</li> <li>Fetches latest responses from API</li> <li>Useful for checking new submissions without full page reload</li> </ol>"},{"location":"v2/frontend/pages/admin/responses-page/#component-breakdown","title":"Component Breakdown","text":""},{"location":"v2/frontend/pages/admin/responses-page/#ant-design-components-used","title":"Ant Design Components Used","text":"<ul> <li>Table \u2014 Main responses list with columns, pagination, row click handler</li> <li>Input \u2014 Search bar with SearchOutlined prefix, 400ms debounce</li> <li>Select \u2014 Status filter dropdown (3 options), Campaign filter dropdown (searchable)</li> <li>Button \u2014 Refresh (header), Approve, Reject, Verify (table actions), Delete (icon button)</li> <li>Tag \u2014 Government level tags, response type tags, status tags</li> <li>Space \u2014 Action button grouping (allows wrapping)</li> <li>Drawer \u2014 Response detail view (520px width, destroyOnClose)</li> <li>Descriptions \u2014 Detail view with labeled fields (column: 1, bordered, size: small)</li> <li>Popconfirm \u2014 Delete confirmation</li> <li>Row, Col \u2014 Responsive grid for filters (3 columns on desktop, stacked on mobile)</li> <li>SafetyCertificateOutlined \u2014 Verified email icon (green)</li> </ul>"},{"location":"v2/frontend/pages/admin/responses-page/#table-columns","title":"Table Columns","text":"<pre><code>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</code></pre> <p>Key patterns: - Conditional button rendering: Approve hidden if APPROVED, Reject hidden if REJECTED - Verify button only shown if <code>representativeEmail</code> exists - <code>actionLoading</code> state tracks which row's action is in progress - <code>wrap</code> on Space allows buttons to wrap on narrow screens</p>"},{"location":"v2/frontend/pages/admin/responses-page/#status-colors","title":"Status Colors","text":"<pre><code>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</code></pre>"},{"location":"v2/frontend/pages/admin/responses-page/#government-level-colors","title":"Government Level Colors","text":"<pre><code>export const GOVERNMENT_LEVEL_COLORS = {\n FEDERAL: 'blue',\n PROVINCIAL: 'purple',\n MUNICIPAL: 'cyan',\n SCHOOL_BOARD: 'magenta',\n};\n</code></pre>"},{"location":"v2/frontend/pages/admin/responses-page/#response-type-labels","title":"Response Type Labels","text":"<pre><code>export const RESPONSE_TYPE_LABELS = {\n EMAIL: 'Email', // Sent via SMTP to representative\n PHONE: 'Phone', // Called representative\n};\n</code></pre>"},{"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":"<p>None \u2014 Responses fetched from API on each interaction.</p>"},{"location":"v2/frontend/pages/admin/responses-page/#local-state","title":"Local State","text":"<pre><code>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</code></pre>"},{"location":"v2/frontend/pages/admin/responses-page/#debounced-search","title":"Debounced Search","text":"<pre><code>const handleSearch = (value: string) => {\n if (searchTimerRef.current) clearTimeout(searchTimerRef.current);\n searchTimerRef.current = setTimeout(() => {\n setSearch(value);\n }, 400);\n};\n</code></pre> <p>Why 400ms? Slightly longer than other pages (300ms) \u2014 response text can be lengthy, giving users more time to type complete phrases.</p>"},{"location":"v2/frontend/pages/admin/responses-page/#per-row-action-loading","title":"Per-Row Action Loading","text":"<pre><code>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</code></pre> <p>Pattern: Only the clicked button shows loading spinner, not all buttons in table.</p>"},{"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 <code>/api/responses</code> List responses (paginated, filtered) PATCH <code>/api/responses/:id/status</code> Update response status (approve/reject) POST <code>/api/responses/:id/resend-verification</code> Resend verification email DELETE <code>/api/responses/:id</code> Delete response GET <code>/api/campaigns</code> List campaigns for filter dropdown"},{"location":"v2/frontend/pages/admin/responses-page/#list-responses","title":"List Responses","text":"<p>Request:</p> <pre><code>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</code></pre> <p>Response:</p> <pre><code>{\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</code></pre> <p>Key fields: - <code>representativeEmail</code> \u2014 Email address (may be null if not available from Represent API) - <code>isVerified</code> \u2014 Email address verified by user clicking link - <code>verifiedBy</code> / <code>verifiedAt</code> \u2014 Verification metadata - <code>upvoteCount</code> \u2014 Denormalized counter (updated on upvote/unvote) - <code>isAnonymous</code> \u2014 If true, hide submitter name/email on public wall</p>"},{"location":"v2/frontend/pages/admin/responses-page/#update-response-status","title":"Update Response Status","text":"<p>Request (Approve):</p> <pre><code>await api.patch(`/responses/${responseId}/status`, { status: 'APPROVED' });\n</code></pre> <p>Request (Reject):</p> <pre><code>await api.patch(`/responses/${responseId}/status`, { status: 'REJECTED' });\n</code></pre> <p>Response:</p> <pre><code>{\n \"id\": \"resp-123\",\n \"status\": \"APPROVED\",\n \"updatedAt\": \"2026-02-11T10:15:00.000Z\"\n}\n</code></pre>"},{"location":"v2/frontend/pages/admin/responses-page/#resend-verification-email","title":"Resend Verification Email","text":"<p>Request:</p> <pre><code>await api.post(`/responses/${responseId}/resend-verification`);\n</code></pre> <p>Response: 204 No Content</p> <p>Email sent to: <code>submittedByEmail</code> with verification link</p> <p>Verification link format:</p> <pre><code>https://app.cmlite.org/responses/verify?token={jwt_token}\n</code></pre> <p>Email template:</p> <pre><code>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</code></pre>"},{"location":"v2/frontend/pages/admin/responses-page/#delete-response","title":"Delete Response","text":"<p>Request:</p> <pre><code>await api.delete(`/responses/${responseId}`);\n</code></pre> <p>Response: 204 No Content</p> <p>Cascade behavior: - ResponseUpvote records deleted (Prisma cascade)</p>"},{"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":"<pre><code><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</code></pre> <p>Pattern: Conditional rendering prevents confusing UI (can't approve an already-approved response).</p>"},{"location":"v2/frontend/pages/admin/responses-page/#clickable-table-rows","title":"Clickable Table Rows","text":"<pre><code><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</code></pre> <p>Pattern: Entire row clickable (except action buttons use <code>stopPropagation</code> to prevent drawer opening when clicking button).</p>"},{"location":"v2/frontend/pages/admin/responses-page/#detail-drawer-with-conditional-verified-info","title":"Detail Drawer with Conditional Verified Info","text":"<pre><code><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</code></pre> <p>Pattern: - <code>whiteSpace: 'pre-wrap'</code> preserves line breaks in response text - <code>maxHeight: 300px</code> + <code>overflow: 'auto'</code> for scrolling long responses - Conditional rendering for verified metadata</p>"},{"location":"v2/frontend/pages/admin/responses-page/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/frontend/pages/admin/responses-page/#debounced-search-400ms","title":"Debounced Search (400ms)","text":"<p>Longer debounce than other pages: - Response text can be several paragraphs - Users need time to type complete search phrases - Reduces API calls during typing</p>"},{"location":"v2/frontend/pages/admin/responses-page/#per-row-action-loading_1","title":"Per-Row Action Loading","text":"<pre><code>const [actionLoading, setActionLoading] = useState<string | null>(null);\n</code></pre> <p>Benefits: - Only one button shows spinner at a time - Other rows remain interactive - Better UX than disabling entire table</p>"},{"location":"v2/frontend/pages/admin/responses-page/#usecallback-optimization","title":"useCallback Optimization","text":"<pre><code>const fetchResponses = useCallback(async (page = 1) => {\n // ... fetch logic\n}, [statusFilter, campaignFilter, search]);\n</code></pre> <p>Memoized function prevents unnecessary re-fetches.</p>"},{"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":"<ul> <li>Filters: Stacked vertically (xs={24})</li> <li>Search bar: Full width</li> <li>Status filter: Full width below search</li> <li>Campaign filter: Full width below status</li> <li>Table: Minimal columns</li> <li>Visible: Representative, Level, Type, Campaign, Status, Actions</li> <li>Hidden: Verified, Upvotes, Submitted</li> <li>Detail drawer: Full screen overlay (width: 100vw)</li> </ul>"},{"location":"v2/frontend/pages/admin/responses-page/#tablet-576px-992px","title":"Tablet (576px - 992px)","text":"<ul> <li>Filters: 3 columns (search: sm={8}, status: sm={5}, campaign: sm={7})</li> <li>Table: Submitted column visible (responsive: ['sm'])</li> <li>Detail drawer: 520px overlay (right side)</li> </ul>"},{"location":"v2/frontend/pages/admin/responses-page/#desktop-992px","title":"Desktop (\u2265 992px)","text":"<ul> <li>Filters: Compact layout</li> <li>Table: All columns visible (Verified, Upvotes: responsive: ['md'])</li> <li>Detail drawer: 520px overlay</li> </ul>"},{"location":"v2/frontend/pages/admin/responses-page/#accessibility","title":"Accessibility","text":"<ul> <li>Keyboard navigation: All buttons focusable via Tab</li> <li>ARIA labels: Icon-only buttons have <code>title</code> attribute</li> <li>Color coding: Status tags use color + text (not color alone)</li> <li>Screen reader support: Descriptions component labels properly associated</li> <li>Focus management: Drawer auto-focuses on open</li> </ul>"},{"location":"v2/frontend/pages/admin/responses-page/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/frontend/pages/admin/responses-page/#verify-button-not-showing","title":"Verify Button Not Showing","text":"<p>Problem: Response has email in table, but Verify button missing.</p> <p>Diagnosis:</p> <p>Check <code>representativeEmail</code> field: <pre><code>{record.representativeEmail && (\n <Button icon={<MailOutlined />} onClick={() => handleResendVerification(record.id)}>\n Verify\n </Button>\n)}\n</code></pre></p> <p>Common Issues:</p> <ol> <li>Email null in database:</li> <li>Represent API didn't return email for this representative</li> <li>Some reps don't have public emails</li> <li> <p>Solution: Not fixable (representative doesn't have email)</p> </li> <li> <p>Email not fetched from API:</p> </li> <li>Check API response includes <code>representativeEmail</code> field</li> <li>Solution: Ensure backend includes field in response serialization</li> </ol> <p>Solution: Verify button only shown if email exists. No email = no verification possible.</p>"},{"location":"v2/frontend/pages/admin/responses-page/#approved-response-not-showing-on-public-wall","title":"Approved Response Not Showing on Public Wall","text":"<p>Problem: Approve response, but doesn't appear on <code>/responses/:campaignId</code> page.</p> <p>Diagnosis:</p> <p>Check campaign <code>showResponseWall</code> flag: 1. Navigate to <code>/app/influence/campaigns</code> 2. Edit campaign 3. Verify \"Show Response Wall\" toggle is ON</p> <p>Common Issues:</p> <ol> <li>Response wall disabled for campaign:</li> <li>Campaign <code>showResponseWall</code> is false</li> <li> <p>Solution: Edit campaign, enable Show Response Wall, save</p> </li> <li> <p>Response not verified:</p> </li> <li>Some campaigns require verified responses only</li> <li> <p>Solution: Click Verify button, user clicks email link</p> </li> <li> <p>Browser cache:</p> </li> <li>Hard refresh public page (Ctrl+Shift+R)</li> </ol> <p>Solution: Ensure campaign has response wall enabled + response is approved.</p>"},{"location":"v2/frontend/pages/admin/responses-page/#verification-email-not-sending","title":"Verification Email Not Sending","text":"<p>Problem: Click Verify button \u2192 Success message \u2192 User doesn't receive email.</p> <p>Diagnosis:</p> <p>Check SMTP configuration: 1. Navigate to Settings \u2192 Email tab 2. Verify active provider: Production (not MailHog) 3. Click \"Test Connection\" \u2192 Should succeed</p> <p>Common Issues:</p> <ol> <li>MailHog active (dev mode):</li> <li>Check MailHog UI: http://localhost:8025</li> <li>Email sent to MailHog instead of real inbox</li> <li> <p>Solution: Switch to Production provider in Settings</p> </li> <li> <p>SMTP credentials invalid:</p> </li> <li>Test connection fails</li> <li> <p>Solution: Update SMTP credentials, re-test</p> </li> <li> <p>Spam folder:</p> </li> <li>Email marked as spam</li> <li>Solution: Check user's spam folder, whitelist sender</li> </ol> <p>Solution: Verify SMTP settings, test connection, check MailHog vs Production.</p>"},{"location":"v2/frontend/pages/admin/responses-page/#delete-button-deletes-immediately","title":"Delete Button Deletes Immediately","text":"<p>Problem: Click Delete icon \u2192 Response deletes without confirmation.</p> <p>Diagnosis:</p> <p>Check Popconfirm placement: <pre><code><Popconfirm title=\"Delete this response?\" onConfirm={() => handleDelete(record.id)}>\n <Button icon={<DeleteOutlined />} />\n</Popconfirm>\n</code></pre></p> <p>Solution: Popconfirm should wrap Button. If missing, delete happens immediately (bad UX).</p>"},{"location":"v2/frontend/pages/admin/responses-page/#campaign-filter-dropdown-empty","title":"Campaign Filter Dropdown Empty","text":"<p>Problem: Click Campaign filter \u2192 No options in dropdown.</p> <p>Diagnosis:</p> <p>Check campaigns API endpoint: <pre><code>curl http://localhost:4000/api/campaigns?limit=100\n</code></pre></p> <p>Common Issues:</p> <ol> <li>No campaigns created:</li> <li>Navigate to <code>/app/influence/campaigns</code></li> <li>Create at least one campaign</li> <li> <p>Return to responses page</p> </li> <li> <p>Campaigns API failing:</p> </li> <li>Check API logs: <code>docker compose logs api | grep \"campaigns\"</code></li> <li>Verify database connection</li> </ol> <p>Solution: Create at least one campaign before filtering responses.</p>"},{"location":"v2/frontend/pages/admin/responses-page/#related-documentation","title":"Related Documentation","text":"<ul> <li>Responses Module (Backend) \u2014 API implementation, schemas, verification flow</li> <li>Campaigns Module \u2014 Campaign showResponseWall flag</li> <li>Response Wall (Public) \u2014 Public response submission + display</li> <li>Email Service \u2014 SMTP email sending, verification emails</li> <li>Responses API Reference \u2014 Complete endpoint documentation</li> <li>Influence Feature Guide \u2014 End-to-end response workflow</li> <li>User Guide: Response Moderation \u2014 Moderation best practices</li> <li>Troubleshooting: Response Issues \u2014 Response debugging</li> </ul>"},{"location":"v2/frontend/pages/admin/settings-page/","title":"SettingsPage","text":""},{"location":"v2/frontend/pages/admin/settings-page/#overview","title":"Overview","text":"<p>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.</p> <p>Route: <code>/app/settings</code> Component: <code>admin/src/pages/SettingsPage.tsx</code> (420 lines) Auth Required: Yes (SUPER_ADMIN role recommended for production) Layout: AppLayout</p>"},{"location":"v2/frontend/pages/admin/settings-page/#screenshot","title":"Screenshot","text":"<p>[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.]</p>"},{"location":"v2/frontend/pages/admin/settings-page/#features","title":"Features","text":"<ul> <li>Tabbed interface \u2014 4 organized sections:</li> <li>Organization (branding, logo, footer)</li> <li>Theme Colors (admin + public themes with live preview)</li> <li>Email (SMTP configuration with dual providers)</li> <li>Feature Toggles (enable/disable modules)</li> <li>SMTP provider switching \u2014 Toggle between MailHog (dev) and Production</li> <li>Live theme preview \u2014 Color swatches + gradient preview</li> <li>SMTP testing \u2014 Test connection + send test email</li> <li>Form persistence \u2014 Settings loaded from Zustand store</li> <li>Optimistic updates \u2014 Immediate UI feedback on save</li> <li>ColorPicker integration \u2014 Visual color selection with hex output</li> <li>Segmented control \u2014 Large toggle for SMTP provider switching</li> </ul>"},{"location":"v2/frontend/pages/admin/settings-page/#user-workflow","title":"User Workflow","text":""},{"location":"v2/frontend/pages/admin/settings-page/#updating-organization-settings","title":"Updating Organization Settings","text":"<ol> <li>Navigate to <code>/app/settings</code></li> <li>Verify \"Organization\" tab is selected (default)</li> <li>Modify fields:</li> <li>Organization Name</li> <li>Short Name (max 10 chars, shown in collapsed sidebar)</li> <li>Logo URL</li> <li>Favicon URL</li> <li>Footer Text</li> <li>Login Subtitle</li> <li>Click \"Save Settings\" button at bottom</li> <li>Success message: \"Settings saved successfully\"</li> <li>Changes apply immediately (refresh not required)</li> </ol>"},{"location":"v2/frontend/pages/admin/settings-page/#customizing-theme-colors","title":"Customizing Theme Colors","text":"<ol> <li>Click \"Theme Colors\" tab</li> <li>Modify Admin Theme colors:</li> <li>Primary Color (ColorPicker)</li> <li>Background Color (ColorPicker)</li> <li>Modify Public Theme colors:</li> <li>Primary Color</li> <li>Background Color</li> <li>Container Color</li> <li>Header Gradient (CSS gradient string)</li> <li>View live preview swatches below form</li> <li>Click \"Save Settings\"</li> <li>Theme updates apply on next page load</li> </ol>"},{"location":"v2/frontend/pages/admin/settings-page/#configuring-smtp-email","title":"Configuring SMTP Email","text":"<ol> <li>Click \"Email\" tab</li> <li>Set Sender info:</li> <li>From Name (e.g., \"Changemaker Lite\")</li> <li>From Address (e.g., \"noreply@cmlite.org\")</li> <li>Switch SMTP Provider:</li> <li>Click MailHog or Production segment</li> <li>Confirmation: \"Switched to [provider] SMTP\"</li> <li>Configure Production SMTP:</li> <li>SMTP Host (e.g., smtp.protonmail.ch)</li> <li>SMTP Port (587 for STARTTLS, 465 for SSL)</li> <li>SMTP User</li> <li>SMTP Password</li> <li>Enable Test Mode (optional):</li> <li>Toggle \"Enable Test Mode\" switch</li> <li>Set Test Recipient email</li> <li>All emails redirect to test recipient</li> <li>Click \"Save Settings\"</li> <li>Test configuration:</li> <li>Click \"Test Connection\" \u2192 Verify \"Connection successful\"</li> <li>Click \"Send Test Email\" \u2192 Check inbox for test message</li> </ol>"},{"location":"v2/frontend/pages/admin/settings-page/#testing-smtp-configuration","title":"Testing SMTP Configuration","text":"<ol> <li>Navigate to Email tab</li> <li>Ensure production credentials are saved</li> <li>Switch to \"Production\" provider</li> <li>Click \"Test Connection\" button</li> <li>Wait for result (success/error alert)</li> <li>If successful, click \"Send Test Email\"</li> <li>Check email inbox for test message</li> <li>If failed, review error message and fix credentials</li> </ol>"},{"location":"v2/frontend/pages/admin/settings-page/#enablingdisabling-features","title":"Enabling/Disabling Features","text":"<ol> <li>Click \"Feature Toggles\" tab</li> <li>Toggle switches:</li> <li>Enable Influence (campaigns, responses, reps)</li> <li>Enable Map (locations, cuts, shifts, canvassing)</li> <li>Enable Newsletter (Listmonk integration)</li> <li>Enable Landing Pages (page builder)</li> <li>Info alert: \"Disabling a module hides it from navigation but does not delete data\"</li> <li>Click \"Save Settings\"</li> <li>Navigation menu updates to hide/show disabled modules</li> </ol>"},{"location":"v2/frontend/pages/admin/settings-page/#component-breakdown","title":"Component Breakdown","text":""},{"location":"v2/frontend/pages/admin/settings-page/#ant-design-components-used","title":"Ant Design Components Used","text":"<ul> <li>Typography.Text \u2014 Labels, descriptions</li> <li>Tabs \u2014 Main navigation (4 tabs)</li> <li>Form \u2014 All settings wrapped in single form instance</li> <li>Form.Item \u2014 Individual fields with labels + extra descriptions</li> <li>Input \u2014 Text fields (org name, logo URL, SMTP host, etc.)</li> <li>Input.Password \u2014 SMTP password field (masked)</li> <li>InputNumber \u2014 SMTP port (numeric, min 0, max 65535)</li> <li>Switch \u2014 Boolean toggles (test mode, feature flags)</li> <li>ColorPicker \u2014 Color selection with hex preview</li> <li>Segmented \u2014 SMTP provider toggle (large button style)</li> <li>Tag \u2014 Active provider indicator (green)</li> <li>Alert \u2014 Info messages, connection/send test results</li> <li>Divider \u2014 Section separators</li> <li>Space \u2014 Button grouping</li> <li>Button \u2014 Test actions + save button</li> <li>Spin \u2014 Loading indicator during initial settings fetch</li> </ul>"},{"location":"v2/frontend/pages/admin/settings-page/#tab-structure","title":"Tab Structure","text":"<pre><code>const items = [\n {\n key: 'organization',\n label: 'Organization',\n icon: <SettingOutlined />,\n children: (/* Organization form fields */)\n },\n {\n key: 'theme',\n label: 'Theme Colors',\n children: (/* Theme form fields */)\n },\n {\n key: 'email',\n label: 'Email',\n children: (/* Email form fields */)\n },\n {\n key: 'features',\n label: 'Feature Toggles',\n children: (/* Feature toggle switches */)\n },\n];\n\nreturn (\n <Form form={form} layout=\"vertical\">\n <Tabs items={items} />\n <Button type=\"primary\" icon={<SaveOutlined />} onClick={handleSave}>\n Save Settings\n </Button>\n </Form>\n);\n</code></pre>"},{"location":"v2/frontend/pages/admin/settings-page/#color-swatch-preview","title":"Color Swatch Preview","text":"<pre><code>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</code></pre>"},{"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":"<ul> <li>settings.store \u2014 Centralized settings state</li> <li><code>settings</code> \u2014 Current settings object</li> <li><code>loading</code> \u2014 Loading state</li> <li><code>fetchAdminSettings()</code> \u2014 Load settings from API</li> <li><code>updateSettings(partial)</code> \u2014 Update and persist settings</li> </ul> <pre><code>import { useSettingsStore } from '@/stores/settings.store';\n\nconst { settings, loading, fetchAdminSettings, updateSettings } = useSettingsStore();\n\nuseEffect(() => {\n fetchAdminSettings();\n}, [fetchAdminSettings]);\n</code></pre>"},{"location":"v2/frontend/pages/admin/settings-page/#local-state","title":"Local State","text":"<pre><code>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</code></pre>"},{"location":"v2/frontend/pages/admin/settings-page/#form-initialization","title":"Form Initialization","text":"<pre><code>useEffect(() => {\n if (settings) {\n form.setFieldsValue(settings);\n }\n}, [settings, form]);\n</code></pre> <p>When settings load from store, form automatically populates with current values.</p>"},{"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 <code>/api/settings</code> Load settings (via store) PUT <code>/api/settings</code> Update settings POST <code>/api/settings/email/test-connection</code> Test SMTP connection POST <code>/api/settings/email/test-send</code> Send test email"},{"location":"v2/frontend/pages/admin/settings-page/#save-settings","title":"Save Settings","text":"<pre><code>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</code></pre> <p>Request Payload:</p> <pre><code>{\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</code></pre>"},{"location":"v2/frontend/pages/admin/settings-page/#test-smtp-connection","title":"Test SMTP Connection","text":"<pre><code>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</code></pre> <p>Response (Success):</p> <pre><code>{\n \"success\": true,\n \"message\": \"Connection successful\"\n}\n</code></pre> <p>Response (Failure):</p> <pre><code>{\n \"success\": false,\n \"message\": \"Connection failed: Authentication failed\"\n}\n</code></pre>"},{"location":"v2/frontend/pages/admin/settings-page/#send-test-email","title":"Send Test Email","text":"<pre><code>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</code></pre> <p>Request:</p> <pre><code>{\n \"to\": \"admin@example.com\"\n}\n</code></pre> <p>Response (Success):</p> <pre><code>{\n \"success\": true,\n \"testMode\": false,\n \"recipient\": \"admin@example.com\",\n \"messageId\": \"<abc123@smtp.protonmail.ch>\"\n}\n</code></pre>"},{"location":"v2/frontend/pages/admin/settings-page/#toggle-smtp-provider","title":"Toggle SMTP Provider","text":"<pre><code>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</code></pre> <p>Why clear test results?</p> <p>Test results are provider-specific. Switching providers invalidates previous test results.</p>"},{"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":"<p>Ant Design ColorPicker returns an object with <code>toHexString()</code> method:</p> <pre><code>// 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</code></pre>"},{"location":"v2/frontend/pages/admin/settings-page/#theme-preview","title":"Theme Preview","text":"<pre><code>{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</code></pre>"},{"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":"<p>All settings use one form instance:</p> <pre><code>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</code></pre> <p>Benefits:</p> <ul> <li>Single save operation \u2014 One API call saves all modified fields</li> <li>Consistent validation \u2014 All fields validated together</li> <li>Simplified state \u2014 No need to track which tab has changes</li> </ul>"},{"location":"v2/frontend/pages/admin/settings-page/#optimistic-provider-switching","title":"Optimistic Provider Switching","text":"<p>Provider toggle updates immediately without waiting for API:</p> <pre><code>await updateSettings({ smtpActiveProvider: provider });\nform.setFieldsValue({ smtpActiveProvider: provider }); // Update form immediately\nmessage.success(`Switched to ${provider}`);\n</code></pre> <p>Why optimistic?</p> <ul> <li>Instant feedback \u2014 User sees immediate response</li> <li>Better UX \u2014 No loading delay for simple toggle</li> <li>Safe operation \u2014 Provider toggle is low-risk (can always switch back)</li> </ul>"},{"location":"v2/frontend/pages/admin/settings-page/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/frontend/pages/admin/settings-page/#smtp-test-connection-failing","title":"SMTP Test Connection Failing","text":"<p>Problem: Click \"Test Connection\" \u2192 Error: \"Connection failed: Authentication failed\"</p> <p>Diagnosis:</p> <p>Check SMTP credentials:</p> <pre><code>{\n smtpHost: \"smtp.protonmail.ch\",\n smtpPort: 587,\n smtpUser: \"user@protonmail.com\",\n smtpPass: \"***\"\n}\n</code></pre> <p>Common Issues:</p> <ol> <li>Wrong port:</li> <li>Use 587 for STARTTLS</li> <li>Use 465 for SSL/TLS</li> <li> <p>Port 25 often blocked by ISPs</p> </li> <li> <p>App-specific password required:</p> </li> <li>Gmail requires app-specific passwords (not account password)</li> <li> <p>ProtonMail requires ProtonMail Bridge for SMTP</p> </li> <li> <p>Wrong provider selected:</p> </li> <li>Ensure \"Production\" is selected before testing production credentials</li> </ol> <p>Solution:</p> <ol> <li>Verify credentials with email provider documentation</li> <li>Switch to \"Production\" provider</li> <li>Save settings before testing</li> <li>Check firewall rules (port 587/465 outbound)</li> </ol>"},{"location":"v2/frontend/pages/admin/settings-page/#theme-colors-not-applying","title":"Theme Colors Not Applying","text":"<p>Problem: Change colors, save settings, but theme doesn't update.</p> <p>Diagnosis:</p> <p>Check if page reload is required:</p> <pre><code>// Theme updates apply on NEXT page load, not immediately\nawait updateSettings({ adminColorPrimary: '#ff0000' });\n// Current page still shows old color\n</code></pre> <p>Solution:</p> <p>Refresh page after saving theme changes:</p> <pre><code>const handleSave = async () => {\n await updateSettings(values);\n message.success('Settings saved. Refreshing page...');\n setTimeout(() => window.location.reload(), 1000);\n};\n</code></pre>"},{"location":"v2/frontend/pages/admin/settings-page/#feature-toggle-not-hiding-module","title":"Feature Toggle Not Hiding Module","text":"<p>Problem: Disable \"Enable Influence\" toggle, save, but Influence menu items still visible.</p> <p>Diagnosis:</p> <p>Check AppLayout navigation logic:</p> <pre><code>// AppLayout should check settings.enableInfluence\n{settings.enableInfluence && (\n <SubMenu key=\"influence\" title=\"Influence\">\n {/* Influence menu items */}\n </SubMenu>\n)}\n</code></pre> <p>Solution:</p> <p>Ensure AppLayout reads settings from store and conditionally renders menu items.</p>"},{"location":"v2/frontend/pages/admin/settings-page/#test-email-not-sending","title":"Test Email Not Sending","text":"<p>Problem: Click \"Send Test Email\" \u2192 Success message, but no email in inbox.</p> <p>Diagnosis:</p> <ol> <li> <p>Check active provider: <pre><code>settings.smtpActiveProvider === 'mailhog' // MailHog (dev)\nsettings.smtpActiveProvider === 'production' // Real SMTP\n</code></pre></p> </li> <li> <p>Check test mode: <pre><code>settings.emailTestMode === true // All emails redirect to testEmailRecipient\n</code></pre></p> </li> <li> <p>Check spam folder</p> </li> <li> <p>Check MailHog web UI (http://localhost:8025) if MailHog is active</p> </li> </ol> <p>Solution:</p> <ul> <li>Switch to \"Production\" provider</li> <li>Disable test mode if you want emails to go to actual recipients</li> <li>Save settings before sending test email</li> </ul>"},{"location":"v2/frontend/pages/admin/settings-page/#related-documentation","title":"Related Documentation","text":"<ul> <li>Settings Module \u2014 Backend API reference</li> <li>Email Service \u2014 Email sending service</li> <li>Settings Store \u2014 Settings state management</li> <li>AppLayout Component \u2014 Feature toggle integration</li> <li>User Guide: Site Configuration \u2014 Configuration guide</li> <li>Troubleshooting: Email Issues \u2014 Email debugging</li> </ul>"},{"location":"v2/frontend/pages/admin/shifts-page/","title":"ShiftsPage","text":""},{"location":"v2/frontend/pages/admin/shifts-page/#overview","title":"Overview","text":"<p>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.</p> <p>Route: <code>/app/map/shifts</code> Component: <code>admin/src/pages/ShiftsPage.tsx</code> (757 lines) Auth Required: Yes (SUPER_ADMIN, MAP_ADMIN roles) Layout: AppLayout</p>"},{"location":"v2/frontend/pages/admin/shifts-page/#screenshot","title":"Screenshot","text":"<p>[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.]</p>"},{"location":"v2/frontend/pages/admin/shifts-page/#features","title":"Features","text":""},{"location":"v2/frontend/pages/admin/shifts-page/#core-features","title":"Core Features","text":"<ul> <li>Full CRUD operations \u2014 Create, read, update, delete shifts</li> <li>Advanced search \u2014 300ms debounced search by title or location</li> <li>Status filtering \u2014 Filter by OPEN, FULL, CANCELLED, COMPLETED</li> <li>Statistics dashboard \u2014 6 cards showing total, open, full, cancelled, upcoming, total signups</li> <li>Date/time scheduling \u2014 Date picker + time pickers with 5-minute intervals</li> <li>Volunteer capacity \u2014 Max volunteers setting with progress bar visualization</li> <li>Area assignment \u2014 Assign shift to a canvass cut (area)</li> <li>Public publishing \u2014 Toggle to show shift on public <code>/shifts</code> page</li> <li>Clickable rows \u2014 Click any row to open signups drawer</li> <li>Responsive table \u2014 Columns hide on smaller screens (Time: md+, Location: lg+, Area: md+)</li> </ul>"},{"location":"v2/frontend/pages/admin/shifts-page/#signup-management","title":"Signup Management","text":"<ul> <li>Signups drawer \u2014 Click shift row to view all confirmed volunteers</li> <li>Manual signup \u2014 Add volunteer by email + name (creates temp user if needed)</li> <li>Signup source tracking \u2014 PUBLIC (self-signup), ADMIN (added by admin)</li> <li>Remove volunteers \u2014 Delete button for each confirmed signup</li> <li>Email all volunteers \u2014 Bulk email button in drawer header</li> <li>Signup stats \u2014 Progress bar in table shows current/max volunteers</li> <li>Auto-status management \u2014 Shift status auto-updates to FULL when capacity reached</li> </ul>"},{"location":"v2/frontend/pages/admin/shifts-page/#shift-status-workflow","title":"Shift Status Workflow","text":"<ol> <li>OPEN \u2014 Shift created, accepting signups</li> <li>FULL \u2014 Max volunteers reached (currentVolunteers >= maxVolunteers)</li> <li>CANCELLED \u2014 Shift cancelled by admin</li> <li>COMPLETED \u2014 Shift date passed (auto-marked by backend)</li> </ol>"},{"location":"v2/frontend/pages/admin/shifts-page/#user-workflow","title":"User Workflow","text":""},{"location":"v2/frontend/pages/admin/shifts-page/#viewing-shifts-list","title":"Viewing Shifts List","text":"<ol> <li>Navigate to <code>/app/map/shifts</code></li> <li>Page loads with statistics cards at top:</li> <li>Total: All shifts count</li> <li>Open: Shifts accepting signups (green)</li> <li>Full: Shifts at capacity (orange)</li> <li>Cancelled: Admin-cancelled shifts (red)</li> <li>Upcoming: Future shifts (blue)</li> <li>Signups: Total confirmed volunteers across all shifts</li> <li>Table shows first 20 shifts (paginated)</li> <li>View shift details:</li> <li>Title (bold)</li> <li>Date (YYYY-MM-DD)</li> <li>Time (HH:mm \u2014 HH:mm format)</li> <li>Location (e.g., \"Campaign HQ, 123 Main St\")</li> <li>Area (cut name if assigned)</li> <li>Volunteers (progress bar X/Y)</li> <li>Status tag (color-coded)</li> <li>Public checkmark icon (if published)</li> <li>Actions (edit, delete)</li> <li>Click any row to open signups drawer</li> </ol>"},{"location":"v2/frontend/pages/admin/shifts-page/#creating-a-shift","title":"Creating a Shift","text":"<ol> <li>Click \"Create Shift\" button in page header</li> <li>Modal opens (560px width) with vertical form</li> <li>Fill required fields:</li> <li>Title \u2014 Shift name (e.g., \"Door Knocking\", \"Phone Banking\")</li> <li>Date \u2014 Date picker (calendar popup)</li> <li>Start Time \u2014 Time picker (HH:mm, 5-minute intervals)</li> <li>End Time \u2014 Time picker (HH:mm, 5-minute intervals)</li> <li>Max Volunteers \u2014 Number input (min: 1)</li> <li>Fill optional fields:</li> <li>Description \u2014 Multi-line text (shift details + instructions)</li> <li>Location \u2014 Text (e.g., \"Campaign HQ, 123 Main St\")</li> <li>Area (Cut) \u2014 Dropdown (select canvass area, searchable)</li> <li>Public \u2014 Switch toggle (default: false)</li> <li>Click \"Create\" button</li> <li>Success message: \"Shift created\"</li> <li>Modal closes, table refreshes to page 1, stats refresh</li> <li>New shift appears with status OPEN</li> </ol>"},{"location":"v2/frontend/pages/admin/shifts-page/#editing-a-shift","title":"Editing a Shift","text":"<ol> <li>Locate shift in table</li> <li>Click Edit icon button (EditOutlined) in Actions column</li> <li>Drawer opens on right side (520px width) with vertical form</li> <li>Modify any fields (same as create, plus Status dropdown):</li> <li>Status options: OPEN, FULL, CANCELLED, COMPLETED</li> <li>Click \"Save\" button in drawer header</li> <li>Success message: \"Shift updated\"</li> <li>Drawer closes, table refreshes, stats refresh</li> </ol>"},{"location":"v2/frontend/pages/admin/shifts-page/#publishing-a-shift-to-public-page","title":"Publishing a Shift to Public Page","text":"<ol> <li>Open shift in edit drawer</li> <li>Toggle \"Public\" switch to ON</li> <li>Click \"Save\"</li> <li>Shift now visible on public <code>/shifts</code> page</li> <li>Users can self-signup via public page</li> <li>Signups source tracked as PUBLIC</li> </ol>"},{"location":"v2/frontend/pages/admin/shifts-page/#assigning-a-cut-area-to-shift","title":"Assigning a Cut (Area) to Shift","text":"<ol> <li>Open shift in edit drawer</li> <li>Click \"Area (Cut)\" dropdown</li> <li>Search for cut by name</li> <li>Select cut from list</li> <li>Click \"Save\"</li> <li>Volunteer portal integration:</li> <li>Volunteers assigned to this shift now see it in <code>/volunteer/assignments</code> page</li> <li>Shift with cut enables volunteer canvassing workflow</li> <li>No cut = general shift (no canvass area)</li> </ol>"},{"location":"v2/frontend/pages/admin/shifts-page/#viewing-shift-signups","title":"Viewing Shift Signups","text":"<ol> <li>Click any shift row in table</li> <li>Signups drawer opens on right side (640px width)</li> <li>Drawer header shows:</li> <li>TeamOutlined icon + \"Signups \u2014 {Shift Title}\"</li> <li>\"Email All\" button in header (disabled if no confirmed volunteers)</li> <li>Info card at top displays shift summary:</li> <li>Date</li> <li>Time (start \u2014 end)</li> <li>Volunteers (current / max)</li> <li>Table shows confirmed volunteers:</li> <li>Columns: Email, Name, Phone, Source (PUBLIC/ADMIN tag), Date, Remove button</li> <li>Pagination: 20 per page (if > 20 signups)</li> <li>Cancelled signups hidden (filtered out)</li> <li>Add volunteer section at bottom:</li> <li>Email input (required)</li> <li>Name input (optional)</li> <li>\"Add\" button (disabled if email empty)</li> </ol>"},{"location":"v2/frontend/pages/admin/shifts-page/#manually-adding-a-volunteer","title":"Manually Adding a Volunteer","text":"<ol> <li>Open signups drawer for any shift</li> <li>Scroll to bottom \"Add volunteer\" section</li> <li>Enter email (required)</li> <li>Enter name (optional)</li> <li>Click \"Add\" button</li> <li>Backend logic:</li> <li>If user exists: Create ShiftSignup record</li> <li>If user doesn't exist: Create temp User + ShiftSignup</li> <li>Signup source: ADMIN</li> <li>Signup status: CONFIRMED</li> <li>Success message: \"Volunteer added\"</li> <li>Table refreshes with new volunteer</li> <li>Email and name inputs clear</li> <li>Main shifts table progress bar updates</li> </ol> <p>Temp user creation: - Role: TEMP - Email: provided email - Password: Readable format (e.g., \"BlueEagle42\") - Expires: shift date + 1 day - Used for public signups without account</p>"},{"location":"v2/frontend/pages/admin/shifts-page/#removing-a-volunteer","title":"Removing a Volunteer","text":"<ol> <li>Open signups drawer</li> <li>Locate volunteer in table</li> <li>Click Delete icon button (red, last column)</li> <li>Popconfirm: \"Remove this volunteer?\"</li> <li>Click \"OK\"</li> <li>Success message: \"Volunteer removed\"</li> <li>Table refreshes (volunteer row disappears)</li> <li>Main shifts table progress bar updates</li> <li>Shift status may change from FULL to OPEN if capacity now available</li> </ol>"},{"location":"v2/frontend/pages/admin/shifts-page/#emailing-all-volunteers","title":"Emailing All Volunteers","text":"<ol> <li>Open signups drawer with confirmed volunteers</li> <li>Click \"Email All\" button in drawer header</li> <li>Backend sends email to all confirmed volunteers:</li> <li>Email template: Shift details (title, date, time, location, description)</li> <li>Subject: \"Shift Reminder: {Shift Title}\"</li> <li>From: Site settings sender (e.g., \"Changemaker Lite noreply@cmlite.org\")</li> <li>Success message: \"Emailed N volunteer(s)\" (or \"N sent, M failed\" if failures)</li> <li>Email uses SMTP settings from Settings page</li> </ol>"},{"location":"v2/frontend/pages/admin/shifts-page/#searching-and-filtering","title":"Searching and Filtering","text":"<ol> <li>Search bar (top left):</li> <li>Type title or location keywords</li> <li>300ms debounce (waits for typing pause)</li> <li>Search resets pagination to page 1</li> <li>Status filter dropdown (top right):</li> <li>Select OPEN, FULL, CANCELLED, or COMPLETED</li> <li>Filter resets pagination to page 1</li> <li>Clear to show all shifts</li> <li>Filters persist during pagination</li> </ol>"},{"location":"v2/frontend/pages/admin/shifts-page/#deleting-a-shift","title":"Deleting a Shift","text":"<ol> <li>Locate shift in table</li> <li>Click Delete icon button (DeleteOutlined) in Actions column</li> <li>Popconfirm: \"Delete this shift?\"</li> <li>Click \"OK\" to confirm</li> <li>Success message: \"Shift deleted\"</li> <li>Table refreshes, stats refresh</li> <li>Cascade behavior: All ShiftSignup records also deleted (Prisma cascade)</li> </ol>"},{"location":"v2/frontend/pages/admin/shifts-page/#component-breakdown","title":"Component Breakdown","text":""},{"location":"v2/frontend/pages/admin/shifts-page/#ant-design-components-used","title":"Ant Design Components Used","text":"<ul> <li>Table \u2014 Main shifts list + signups table in drawer</li> <li>Button \u2014 Create (primary), edit, delete, add volunteer, email all</li> <li>Input \u2014 Search bar, email input, name input (signups drawer)</li> <li>Select \u2014 Status filter dropdown, area (cut) dropdown</li> <li>Tag \u2014 Status tags (color-coded), signup source tags</li> <li>Space \u2014 Action button grouping, drawer header</li> <li>Card \u2014 Statistics cards (6 cards), shift summary card in signups drawer</li> <li>Statistic \u2014 Numeric displays with icons + prefixes</li> <li>Progress \u2014 Volunteer capacity progress bar (in Volunteers column)</li> <li>Modal \u2014 Create shift form</li> <li>Drawer \u2014 Edit shift (520px), signups drawer (640px)</li> <li>Form \u2014 Create/edit shift forms</li> <li>Form.Item \u2014 Field wrappers with labels + rules</li> <li>Input.TextArea \u2014 Description field (multi-line)</li> <li>DatePicker \u2014 Date selection with calendar popup</li> <li>TimePicker \u2014 Time selection with hour/minute dropdowns (5-minute steps)</li> <li>InputNumber \u2014 Max volunteers numeric input (min: 1)</li> <li>Switch \u2014 Public toggle (valuePropName=\"checked\")</li> <li>Row, Col \u2014 Responsive grid for stats cards, date/time fields</li> <li>Popconfirm \u2014 Delete confirmation (shift + volunteer removal)</li> <li>Typography.Text \u2014 Labels, descriptions</li> </ul>"},{"location":"v2/frontend/pages/admin/shifts-page/#table-columns-main-shifts-table","title":"Table Columns (Main Shifts Table)","text":"<pre><code>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</code></pre> <p>Key patterns: - <code>_count.signups</code> aggregation from Prisma (confirmed volunteers count) - <code>responsive</code> array hides columns on smaller screens - Progress bar shows visual capacity indicator (turns red when full) - <code>onRow</code> prop makes entire row clickable to open signups drawer</p>"},{"location":"v2/frontend/pages/admin/shifts-page/#signups-table-columns","title":"Signups Table Columns","text":"<pre><code>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</code></pre> <p>Filter: Table only shows CONFIRMED signups: <pre><code><Table dataSource={signups.filter((s) => s.status === 'CONFIRMED')} />\n</code></pre></p>"},{"location":"v2/frontend/pages/admin/shifts-page/#status-colors","title":"Status Colors","text":"<pre><code>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</code></pre>"},{"location":"v2/frontend/pages/admin/shifts-page/#signup-source-colors","title":"Signup Source Colors","text":"<pre><code>export const SIGNUP_SOURCE_COLORS = {\n PUBLIC: 'blue', // User signed up via public /shifts page\n ADMIN: 'purple', // Admin added manually\n};\n</code></pre>"},{"location":"v2/frontend/pages/admin/shifts-page/#form-fields","title":"Form Fields","text":"<pre><code>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</code></pre> <p>Reusable pattern: Same form fields for create + edit, with conditional Status field in edit mode.</p>"},{"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":"<p>None \u2014 Shifts fetched from API on each interaction. No global state required.</p>"},{"location":"v2/frontend/pages/admin/shifts-page/#local-state","title":"Local State","text":"<pre><code>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</code></pre>"},{"location":"v2/frontend/pages/admin/shifts-page/#debounced-search","title":"Debounced Search","text":"<pre><code>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</code></pre> <p>Why 300ms? Same pattern as other pages \u2014 prevents API spam while typing.</p>"},{"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 <code>/api/map/shifts</code> List shifts (paginated, filtered) GET <code>/api/map/shifts/stats</code> Fetch statistics (counts by status) GET <code>/api/map/shifts/:id</code> Fetch single shift with signups POST <code>/api/map/shifts</code> Create shift PUT <code>/api/map/shifts/:id</code> Update shift DELETE <code>/api/map/shifts/:id</code> Delete shift (cascade signups) POST <code>/api/map/shifts/:id/signups</code> Add volunteer manually (admin) DELETE <code>/api/map/shifts/:id/signups/:signupId</code> Remove volunteer POST <code>/api/map/shifts/:id/email-details</code> Email all confirmed volunteers"},{"location":"v2/frontend/pages/admin/shifts-page/#list-shifts","title":"List Shifts","text":"<p>Request:</p> <pre><code>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</code></pre> <p>Response:</p> <pre><code>{\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</code></pre> <p>Key fields: - <code>cutId</code> \u2014 Foreign key to Cut (polygon area) - <code>cut</code> \u2014 Nested cut object (if assigned) - <code>currentVolunteers</code> \u2014 Confirmed signups count - <code>_count.signups</code> \u2014 Prisma aggregation (confirmed signups count) - <code>startTime</code>, <code>endTime</code> \u2014 24-hour format strings (HH:mm)</p>"},{"location":"v2/frontend/pages/admin/shifts-page/#fetch-shift-statistics","title":"Fetch Shift Statistics","text":"<p>Request:</p> <pre><code>const { data } = await api.get<ShiftStats>('/map/shifts/stats');\n</code></pre> <p>Response:</p> <pre><code>{\n \"total\": 47,\n \"open\": 23,\n \"full\": 8,\n \"cancelled\": 2,\n \"completed\": 14,\n \"upcoming\": 31,\n \"totalSignups\": 287\n}\n</code></pre> <p>Upcoming calculation: Future shifts (date >= today)</p>"},{"location":"v2/frontend/pages/admin/shifts-page/#create-shift","title":"Create Shift","text":"<p>Request:</p> <pre><code>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</code></pre> <p>Response:</p> <pre><code>{\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</code></pre> <p>Default values: - <code>status</code> \u2014 OPEN - <code>currentVolunteers</code> \u2014 0 - <code>isPublic</code> \u2014 false (if not specified)</p>"},{"location":"v2/frontend/pages/admin/shifts-page/#update-shift","title":"Update Shift","text":"<p>Request:</p> <pre><code>const payload = {\n status: \"CANCELLED\",\n description: \"Cancelled due to weather\",\n};\n\nawait api.put(`/map/shifts/${shiftId}`, payload);\n</code></pre> <p>Partial updates: Only send changed fields.</p>"},{"location":"v2/frontend/pages/admin/shifts-page/#add-volunteer-manual-signup","title":"Add Volunteer (Manual Signup)","text":"<p>Request:</p> <pre><code>await api.post(`/map/shifts/${shiftId}/signups`, {\n userEmail: 'volunteer@example.com',\n userName: 'Jane Doe', // Optional\n});\n</code></pre> <p>Response:</p> <pre><code>{\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</code></pre> <p>Temp user creation logic: - If <code>volunteer@example.com</code> exists \u2192 link to existing user - If doesn't exist \u2192 create temp user: <pre><code>{\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</code></pre></p>"},{"location":"v2/frontend/pages/admin/shifts-page/#email-all-volunteers","title":"Email All Volunteers","text":"<p>Request:</p> <pre><code>const { data } = await api.post<{ sent: number; failed: number }>(\n `/map/shifts/${shiftId}/email-details`\n);\n</code></pre> <p>Response:</p> <pre><code>{\n \"sent\": 8,\n \"failed\": 0\n}\n</code></pre> <p>Email template:</p> <pre><code>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</code></pre> <p>SMTP: Uses site settings (Settings page \u2192 Email tab)</p>"},{"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":"<pre><code><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</code></pre> <p>Pattern: Entire row clickable except action buttons (edit/delete use <code>stopPropagation</code>).</p>"},{"location":"v2/frontend/pages/admin/shifts-page/#progress-bar-for-volunteer-capacity","title":"Progress Bar for Volunteer Capacity","text":"<pre><code>{\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</code></pre> <p>Visual feedback: - Green progress bar: < 100% - Red progress bar: = 100% (full, status \"exception\") - Format shows: \"8/15\" (8 confirmed out of 15 max)</p>"},{"location":"v2/frontend/pages/admin/shifts-page/#datetime-payload-formatting","title":"Date/Time Payload Formatting","text":"<pre><code>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</code></pre> <p>Why format? DatePicker and TimePicker return Dayjs objects. Backend expects ISO date string + HH:mm time strings.</p>"},{"location":"v2/frontend/pages/admin/shifts-page/#edit-form-pre-fill","title":"Edit Form Pre-Fill","text":"<pre><code>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</code></pre> <p>Why <code>dayjs(shift.startTime, 'HH:mm')</code>? TimePicker needs Dayjs object with specific format. Backend stores as \"10:00\" string, convert to Dayjs with HH:mm format.</p>"},{"location":"v2/frontend/pages/admin/shifts-page/#conditional-status-field","title":"Conditional Status Field","text":"<pre><code>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</code></pre> <p>Why conditional? Status dropdown only shown in edit mode. Create form defaults to OPEN (set by backend).</p>"},{"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":"<p>Same 300ms debounce pattern as other pages: - Prevents API spam while typing - Only fires after user pauses - Cleanup on unmount</p>"},{"location":"v2/frontend/pages/admin/shifts-page/#responsive-column-hiding","title":"Responsive Column Hiding","text":"<pre><code>{ title: 'Time', responsive: ['md'] } // Hide on < 768px\n{ title: 'Location', responsive: ['lg'] } // Hide on < 992px\n{ title: 'Area', responsive: ['md'] } // Hide on < 768px\n</code></pre> <p>Mobile users see: Title, Date, Volunteers, Status, Public, Actions</p>"},{"location":"v2/frontend/pages/admin/shifts-page/#usecallback-optimization","title":"useCallback Optimization","text":"<pre><code>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</code></pre> <p>Memoized functions prevent unnecessary re-renders.</p>"},{"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":"<ul> <li>Stats cards: 2 columns (xs={12})</li> <li>Search bar: Full width</li> <li>Status filter: Full width below search</li> <li>Table: Minimal columns (Title, Date, Volunteers, Status, Actions)</li> <li>Signups drawer: Full screen overlay (width: 100%)</li> </ul>"},{"location":"v2/frontend/pages/admin/shifts-page/#tablet-576px-992px","title":"Tablet (576px - 992px)","text":"<ul> <li>Stats cards: 3-4 columns (sm={4} or sm={6})</li> <li>Search bar: Half width (sm={12})</li> <li>Status filter: Quarter width (sm={6})</li> <li>Table: Time + Area columns visible</li> </ul>"},{"location":"v2/frontend/pages/admin/shifts-page/#desktop-992px","title":"Desktop (\u2265 992px)","text":"<ul> <li>Stats cards: 6 columns (md={4})</li> <li>Filters: Compact (search \u2153, filter \u2159)</li> <li>Table: All columns visible (Location, Date)</li> <li>Signups drawer: 640px overlay (right side)</li> </ul>"},{"location":"v2/frontend/pages/admin/shifts-page/#accessibility","title":"Accessibility","text":"<ul> <li>Keyboard navigation: All buttons, inputs, selects focusable via Tab</li> <li>ARIA labels: Icon buttons have <code>title</code> attribute</li> <li>Form validation: Required fields marked, inline error messages</li> <li>Color contrast: Status tags use Ant Design defaults (WCAG AA compliant)</li> <li>Screen reader support: Form labels properly associated</li> <li>Focus management: Modals/drawers auto-focus first input on open</li> </ul>"},{"location":"v2/frontend/pages/admin/shifts-page/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/frontend/pages/admin/shifts-page/#shift-status-not-auto-updating-to-full","title":"Shift Status Not Auto-Updating to FULL","text":"<p>Problem: Shift reaches max volunteers (8/8), but status stays OPEN instead of changing to FULL.</p> <p>Diagnosis:</p> <p>Backend auto-status logic should run on every signup: <pre><code>if (currentVolunteers >= maxVolunteers) {\n await prisma.shift.update({\n where: { id: shiftId },\n data: { status: 'FULL' },\n });\n}\n</code></pre></p> <p>Common Issues:</p> <ol> <li>Backend logic not running:</li> <li>Check API logs: <code>docker compose logs api | grep \"shift status\"</code></li> <li> <p>Verify signup endpoint includes auto-status update</p> </li> <li> <p>Race condition:</p> </li> <li>Multiple signups at same time (public + admin)</li> <li> <p>Solution: Use Prisma transaction for atomic updates</p> </li> <li> <p>Status manually set:</p> </li> <li>Admin changed status to OPEN in edit drawer</li> <li>Solution: Status field warning: \"Auto-updates to FULL when capacity reached\"</li> </ol> <p>Solution:</p> <p>Refresh page to see latest status. Backend should auto-update on next signup/removal.</p>"},{"location":"v2/frontend/pages/admin/shifts-page/#email-all-volunteers-fails","title":"Email All Volunteers Fails","text":"<p>Problem: Click \"Email All\" button \u2192 Error: \"Failed to email volunteers\"</p> <p>Diagnosis:</p> <p>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</p> <p>Common Issues:</p> <ol> <li>MailHog active (dev mode):</li> <li>Switch to Production provider</li> <li> <p>Save settings</p> </li> <li> <p>SMTP credentials invalid:</p> </li> <li>Test connection fails</li> <li>Update credentials</li> <li> <p>Re-test before emailing</p> </li> <li> <p>No confirmed volunteers:</p> </li> <li>Email All button disabled if 0 confirmed</li> <li>Check signups drawer table (only CONFIRMED shown)</li> </ol> <p>Solution:</p> <ol> <li>Fix SMTP settings</li> <li>Test connection</li> <li>Retry Email All</li> </ol>"},{"location":"v2/frontend/pages/admin/shifts-page/#volunteer-not-appearing-in-signups","title":"Volunteer Not Appearing in Signups","text":"<p>Problem: Add volunteer by email \u2192 Success message \u2192 Volunteer not in signups table</p> <p>Diagnosis:</p> <p>Check signups drawer filter: <pre><code><Table dataSource={signups.filter((s) => s.status === 'CONFIRMED')} />\n</code></pre></p> <p>Cancelled signups hidden.</p> <p>Common Issues:</p> <ol> <li>Volunteer added but immediately cancelled:</li> <li>Check backend logs for cancellation endpoint calls</li> <li> <p>Verify signup status in database</p> </li> <li> <p>Wrong shift:</p> </li> <li>Added to different shift</li> <li> <p>Verify shift ID in URL when opening drawer</p> </li> <li> <p>Duplicate email:</p> </li> <li>Volunteer already signed up</li> <li>Backend returns 400: \"User already signed up for this shift\"</li> <li>Check error message</li> </ol> <p>Solution:</p> <p>Refresh drawer: Close and re-open signups drawer to fetch latest data.</p>"},{"location":"v2/frontend/pages/admin/shifts-page/#area-cut-dropdown-empty","title":"Area (Cut) Dropdown Empty","text":"<p>Problem: Create/edit shift \u2192 Area dropdown shows no options</p> <p>Diagnosis:</p> <p>Check cuts API endpoint: <pre><code>curl http://localhost:4000/api/map/cuts\n</code></pre></p> <p>Common Issues:</p> <ol> <li>No cuts created yet:</li> <li>Navigate to <code>/app/map/cuts</code></li> <li>Create at least one cut (polygon boundary)</li> <li> <p>Return to shifts page</p> </li> <li> <p>Cuts API failing:</p> </li> <li>Check API logs: <code>docker compose logs api | grep \"cuts\"</code></li> <li> <p>Verify database connection</p> </li> <li> <p>Cuts fetch not called:</p> </li> <li>Check browser console for errors</li> <li>Verify <code>fetchCuts()</code> called in <code>useEffect</code></li> </ol> <p>Solution:</p> <p>Create at least one cut in CutsPage before assigning to shifts.</p>"},{"location":"v2/frontend/pages/admin/shifts-page/#public-shift-not-showing-on-public-page","title":"Public Shift Not Showing on Public Page","text":"<p>Problem: Set isPublic to true, save shift \u2192 Public <code>/shifts</code> page doesn't show it</p> <p>Diagnosis:</p> <p>Check shift criteria for public page: - Status: OPEN or FULL (not CANCELLED or COMPLETED) - isPublic: true - Date: Future (not past)</p> <p>Common Issues:</p> <ol> <li>Shift date in past:</li> <li>Past shifts hidden from public page</li> <li> <p>Edit shift, update date to future</p> </li> <li> <p>Status CANCELLED:</p> </li> <li>Cancelled shifts hidden from public page</li> <li> <p>Change status to OPEN</p> </li> <li> <p>Browser cache:</p> </li> <li>Hard refresh public page (Ctrl+Shift+R)</li> </ol> <p>Solution:</p> <p>Verify all 3 criteria met: OPEN/FULL status, isPublic true, future date.</p>"},{"location":"v2/frontend/pages/admin/shifts-page/#related-documentation","title":"Related Documentation","text":"<ul> <li>Shifts Module (Backend) \u2014 API implementation, schemas, service functions</li> <li>Shift Signups \u2014 Signup creation, temp users, email logic</li> <li>Cuts Module \u2014 Polygon boundaries for shift areas</li> <li>Public Shifts Page \u2014 Public shift signup page</li> <li>Volunteer Shifts Page \u2014 Volunteer portal assignments</li> <li>Shifts API Reference \u2014 Complete endpoint documentation</li> <li>Map Feature Guide \u2014 End-to-end shift workflow</li> <li>User Guide: Volunteer Coordination \u2014 Shift scheduling best practices</li> <li>Troubleshooting: Shift Issues \u2014 Shift debugging</li> </ul>"},{"location":"v2/frontend/pages/admin/users-page/","title":"UsersPage","text":""},{"location":"v2/frontend/pages/admin/users-page/#overview","title":"Overview","text":"<p>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.</p> <p>Route: <code>/app/users</code> Component: <code>admin/src/pages/UsersPage.tsx</code> (400+ lines) Auth Required: Yes (SUPER_ADMIN, INFLUENCE_ADMIN, MAP_ADMIN) Layout: AppLayout</p>"},{"location":"v2/frontend/pages/admin/users-page/#screenshot","title":"Screenshot","text":"<p>[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.]</p>"},{"location":"v2/frontend/pages/admin/users-page/#features","title":"Features","text":"<ul> <li>Full CRUD operations \u2014 Create, Read, Update, Delete users</li> <li>Advanced search \u2014 Debounced search by email/name (300ms delay)</li> <li>Dual filtering \u2014 Filter by role AND status simultaneously</li> <li>Pagination \u2014 Configurable page size (20 per page default)</li> <li>Color-coded roles \u2014 Visual role identification with Ant Design tags</li> <li>SUPER_ADMIN: red</li> <li>INFLUENCE_ADMIN: volcano (orange-red)</li> <li>MAP_ADMIN: orange</li> <li>USER: blue</li> <li>TEMP: default (gray)</li> <li>Status indicators \u2014 Color-coded status tags</li> <li>ACTIVE: green</li> <li>INACTIVE: gray</li> <li>SUSPENDED: red</li> <li>EXPIRED: orange</li> <li>Temp user management \u2014 Create temporary users with expiration dates</li> <li>DatePicker for expiration \u2014 Visual calendar for setting expiry</li> <li>Optimistic UI \u2014 Immediate feedback on actions</li> <li>Responsive table \u2014 Mobile-friendly with scroll overflow</li> </ul>"},{"location":"v2/frontend/pages/admin/users-page/#user-workflow","title":"User Workflow","text":""},{"location":"v2/frontend/pages/admin/users-page/#creating-a-user","title":"Creating a User","text":"<ol> <li>Click \"Create User\" button (top right)</li> <li>Modal opens with form fields:</li> <li>Email (required)</li> <li>Name (optional)</li> <li>Phone (optional)</li> <li>Password (required, auto-generated option available)</li> <li>Role (dropdown: Super Admin, Influence Admin, Map Admin, User, Temp)</li> <li>Status (dropdown: Active, Inactive, Suspended, Expired)</li> <li>Expiration Date (optional, for TEMP users)</li> <li>Fill out form fields</li> <li>Click \"Create\"</li> <li>Success message: \"User created\"</li> <li>Modal closes, table refreshes to page 1</li> </ol>"},{"location":"v2/frontend/pages/admin/users-page/#editing-a-user","title":"Editing a User","text":"<ol> <li>Click Edit icon in Actions column</li> <li>Modal opens with pre-populated form</li> <li>Modify fields (password optional, leave blank to keep existing)</li> <li>Click \"Save\"</li> <li>Success message: \"User updated\"</li> <li>Modal closes, table data refreshes</li> </ol>"},{"location":"v2/frontend/pages/admin/users-page/#deleting-a-user","title":"Deleting a User","text":"<ol> <li>Click Delete icon in Actions column</li> <li>Popconfirm appears: \"Are you sure you want to delete this user?\"</li> <li>Click \"Yes\"</li> <li>Success message: \"User deleted\"</li> <li>Table data refreshes</li> </ol>"},{"location":"v2/frontend/pages/admin/users-page/#searching-users","title":"Searching Users","text":"<ol> <li>Type in search input (top left)</li> <li>Wait 300ms (debounce delay)</li> <li>Table automatically filters results</li> <li>Search matches email OR name (case-insensitive)</li> <li>Resets to page 1 automatically</li> </ol>"},{"location":"v2/frontend/pages/admin/users-page/#filtering-by-rolestatus","title":"Filtering by Role/Status","text":"<ol> <li>Select role from Role Filter dropdown</li> <li>OR/AND select status from Status Filter dropdown</li> <li>Table automatically filters results</li> <li>Filters combine with search (all must match)</li> <li>Resets to page 1 automatically</li> </ol>"},{"location":"v2/frontend/pages/admin/users-page/#component-breakdown","title":"Component Breakdown","text":""},{"location":"v2/frontend/pages/admin/users-page/#ant-design-components-used","title":"Ant Design Components Used","text":"<ul> <li>Table \u2014 Main data table with columns, pagination, sorting</li> <li>Button \u2014 Create User, modal actions (Create, Save, Cancel)</li> <li>Input \u2014 Search input, text fields (email, name, phone, password)</li> <li>Select \u2014 Role and status dropdowns</li> <li>Tag \u2014 Color-coded role and status indicators</li> <li>Space \u2014 Action button grouping</li> <li>Modal \u2014 Create and edit user forms</li> <li>Form \u2014 Form validation and submission</li> <li>InputNumber \u2014 Expire days input</li> <li>DatePicker \u2014 Expiration date picker</li> <li>Popconfirm \u2014 Delete confirmation</li> <li>message \u2014 Toast notifications (success, error)</li> <li>Typography.Title \u2014 Page heading</li> <li>Row, Col \u2014 Responsive form layout</li> </ul>"},{"location":"v2/frontend/pages/admin/users-page/#table-columns","title":"Table Columns","text":"Column Key Render Sortable Email <code>email</code> Plain text No Name <code>name</code> Plain text No Role <code>role</code> Color-coded Tag No Status <code>status</code> Color-coded Tag No Created At <code>createdAt</code> Formatted date (MMM DD, YYYY) No Actions - Edit + Delete icons No <p>Column Configuration:</p> <pre><code>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</code></pre>"},{"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":"<pre><code>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</code></pre>"},{"location":"v2/frontend/pages/admin/users-page/#zustand-stores-used","title":"Zustand Stores Used","text":"<ul> <li>auth.store \u2014 Not directly used, but auth context ensures only admins access page</li> </ul>"},{"location":"v2/frontend/pages/admin/users-page/#search-debouncing","title":"Search Debouncing","text":"<pre><code>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</code></pre> <p>Why 300ms?</p> <ul> <li>Fast enough \u2014 Users perceive instant response</li> <li>Reduces API calls \u2014 Prevents API spam during typing</li> <li>Balances UX \u2014 Not too slow (500ms+ feels laggy)</li> </ul>"},{"location":"v2/frontend/pages/admin/users-page/#api-integration","title":"API Integration","text":""},{"location":"v2/frontend/pages/admin/users-page/#endpoints-used","title":"Endpoints Used","text":"Method Endpoint Purpose GET <code>/api/users</code> List users with pagination/filters POST <code>/api/users</code> Create new user PUT <code>/api/users/:id</code> Update user DELETE <code>/api/users/:id</code> Delete user"},{"location":"v2/frontend/pages/admin/users-page/#fetch-users-with-filters","title":"Fetch Users (with Filters)","text":"<pre><code>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</code></pre> <p>Response Format:</p> <pre><code>{\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</code></pre>"},{"location":"v2/frontend/pages/admin/users-page/#create-user","title":"Create User","text":"<pre><code>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</code></pre> <p>Request Payload:</p> <pre><code>{\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</code></pre>"},{"location":"v2/frontend/pages/admin/users-page/#update-user","title":"Update User","text":"<pre><code>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</code></pre> <p>Update Payload (Partial):</p> <pre><code>{\n \"name\": \"Updated Name\",\n \"role\": \"MAP_ADMIN\",\n \"status\": \"ACTIVE\"\n}\n</code></pre> <p>Note: Password is optional in updates. Leave blank to keep existing password.</p>"},{"location":"v2/frontend/pages/admin/users-page/#delete-user","title":"Delete User","text":"<pre><code>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</code></pre>"},{"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":"<pre><code><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</code></pre> <p>Password Policy:</p> <ul> <li>Minimum 12 characters</li> <li>At least one uppercase letter</li> <li>At least one lowercase letter</li> <li>At least one digit</li> </ul>"},{"location":"v2/frontend/pages/admin/users-page/#edit-user-form-rules","title":"Edit User Form Rules","text":"<p>Same as create form, but password is optional:</p> <pre><code><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</code></pre>"},{"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":"<p>Prevents excessive API calls during typing:</p> <pre><code>const handleSearchChange = (value: string) => {\n setSearch(value);\n clearTimeout(searchTimerRef.current);\n searchTimerRef.current = setTimeout(() => setDebouncedSearch(value), 300);\n};\n</code></pre> <p>Performance Impact:</p> <ul> <li>Without debounce: 10 keystrokes = 10 API calls</li> <li>With 300ms debounce: 10 keystrokes = 1-2 API calls (significant reduction)</li> </ul>"},{"location":"v2/frontend/pages/admin/users-page/#usecallback-for-fetchusers","title":"useCallback for fetchUsers","text":"<p>Prevents unnecessary re-creation of fetch function:</p> <pre><code>const fetchUsers = useCallback(async (params?: UsersListParams) => {\n // ... fetch logic\n}, [pagination.page, pagination.limit, debouncedSearch, roleFilter, statusFilter]);\n</code></pre> <p>Why useCallback?</p> <ul> <li>Memoization \u2014 Function reference stays stable unless dependencies change</li> <li>Prevents re-renders \u2014 Child components can use <code>React.memo</code> effectively</li> <li>useEffect optimization \u2014 Avoids infinite loops in useEffect</li> </ul>"},{"location":"v2/frontend/pages/admin/users-page/#pagination","title":"Pagination","text":"<p>Server-side pagination reduces memory usage:</p> <ul> <li>Client-side (bad): Fetch all 10,000 users \u2192 Paginate in browser \u2192 High memory usage</li> <li>Server-side (good): Fetch 20 users per page \u2192 Low memory usage</li> </ul>"},{"location":"v2/frontend/pages/admin/users-page/#responsive-design","title":"Responsive Design","text":""},{"location":"v2/frontend/pages/admin/users-page/#table-scroll","title":"Table Scroll","text":"<p>Table uses horizontal scroll on mobile:</p> <pre><code><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</code></pre>"},{"location":"v2/frontend/pages/admin/users-page/#modal-forms","title":"Modal Forms","text":"<p>Forms use responsive columns:</p> <pre><code><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</code></pre> <p>Mobile: Fields stack vertically (xs={24}) Tablet+: Fields display side-by-side (sm={12})</p>"},{"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":"<p>Problem: Typing in search input doesn't filter results.</p> <p>Diagnosis:</p> <ol> <li>Check <code>debouncedSearch</code> value in React DevTools</li> <li>Verify <code>fetchUsers</code> is called after 300ms delay</li> <li>Check network tab for API call with <code>search</code> param</li> </ol> <p>Solution:</p> <pre><code>useEffect(() => {\n fetchUsers({ page: 1 });\n}, [debouncedSearch, roleFilter, statusFilter]);\n</code></pre> <p>Ensure <code>debouncedSearch</code> is in dependency array, not <code>search</code>.</p>"},{"location":"v2/frontend/pages/admin/users-page/#failed-to-load-users-error","title":"\"Failed to load users\" Error","text":"<p>Problem: Table shows error message.</p> <p>Diagnosis:</p> <p>Check API response:</p> <pre><code>curl -H \"Authorization: Bearer <token>\" \\\n \"http://api.cmlite.org/api/users?page=1&limit=20\"\n</code></pre> <p>Common Issues:</p> <ol> <li>401 Unauthorized \u2014 Token expired</li> <li>403 Forbidden \u2014 User lacks admin role</li> <li>500 Internal Server Error \u2014 Database connection issue</li> </ol>"},{"location":"v2/frontend/pages/admin/users-page/#delete-confirmation-not-appearing","title":"Delete Confirmation Not Appearing","text":"<p>Problem: Click delete icon but nothing happens.</p> <p>Diagnosis:</p> <p>Check Popconfirm component:</p> <pre><code><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</code></pre> <p>Solution:</p> <p>Ensure Popconfirm wraps the button, not the other way around.</p>"},{"location":"v2/frontend/pages/admin/users-page/#modal-form-not-resetting","title":"Modal Form Not Resetting","text":"<p>Problem: Open create modal, enter data, close modal, reopen \u2192 old data still there.</p> <p>Solution:</p> <p>Reset form on modal close:</p> <pre><code><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</code></pre>"},{"location":"v2/frontend/pages/admin/users-page/#related-documentation","title":"Related Documentation","text":"<ul> <li>Users Module \u2014 Backend API reference</li> <li>Auth Module \u2014 Authentication system</li> <li>AppLayout Component \u2014 Layout wrapper</li> <li>Auth Store \u2014 Authentication state</li> <li>API Client \u2014 Axios instance with interceptors</li> <li>User Guide: User Management \u2014 Admin guide</li> </ul>"},{"location":"v2/frontend/pages/admin/walk-sheet-page/","title":"WalkSheetPage","text":""},{"location":"v2/frontend/pages/admin/walk-sheet-page/#overview","title":"Overview","text":"<p>File: <code>admin/src/pages/WalkSheetPage.tsx</code></p> <p>Route: <code>/app/walk-sheet</code></p> <p>Role Requirements: Any authenticated user (uses <code>authenticate</code> middleware)</p> <p>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.</p> <p>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 <code>@media print</code> rules</p> <p>Layout: Full AppLayout with Print button in header</p> <p>Dependencies: - Ant Design v5 (Button, Typography, Spin, App) - react-router-dom (useOutletContext) - QR code generation via <code>/api/qr</code> endpoint</p>"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#features","title":"Features","text":""},{"location":"v2/frontend/pages/admin/walk-sheet-page/#1-customizable-header","title":"1. Customizable Header","text":"<p>Configurable Fields: - Walk Sheet Title: Main heading (e.g., \"Volunteer Canvassing Walk Sheet\") - Walk Sheet Subtitle: Optional subtitle (e.g., \"Ward 5 - Downtown District\")</p> <p>Source: <code>MapSettings.walkSheetTitle</code> and <code>MapSettings.walkSheetSubtitle</code></p>"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#2-qr-code-section","title":"2. QR Code Section","text":"<p>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 <code>/api/qr?text={url}&size=100</code> endpoint</p> <p>Source: <code>MapSettings.qrCode1Url/Label</code>, <code>qrCode2Url/Label</code>, <code>qrCode3Url/Label</code></p> <p>Example QR Code URLs: - <code>https://cmlite.org/responses/1</code> - Response submission page - <code>https://cmlite.org/shifts</code> - Shift signup page - <code>https://cmlite.org/campaigns</code> - Campaign listing</p>"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#3-volunteer-information-fields","title":"3. Volunteer Information Fields","text":"<p>Pre-printed Fields: - Volunteer: Name line (200px underline) - Date: Date line (120px underline) - Area/Cut: Assignment line (120px underline)</p> <p>Purpose: Volunteer fills these in by hand before starting canvass</p>"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#4-contact-table","title":"4. Contact Table","text":"<p>12 Rows with 8 Columns:</p> <ol> <li># (Number): Row number 1-12 (pre-printed)</li> <li>Name: Blank field for recording contact name</li> <li>Address / Unit: Blank field for building address and unit number</li> <li>Email: Blank field for contact email</li> <li>Phone: Blank field for contact phone number</li> <li>Support: 4 circles for support level (1-4 scale)</li> <li>Circle 1 = Strong Support</li> <li>Circle 2 = Likely Support</li> <li>Circle 3 = Unsure</li> <li>Circle 4 = Oppose</li> <li>Sign: 2 circles for lawn sign interest (R/L)</li> <li>R = Right side of entrance</li> <li>L = Left side of entrance</li> <li>Notes: Blank field for additional notes</li> </ol> <p>Table Styling: - 1px solid borders - 11px font size (print-optimized) - 28px row height (sufficient for handwriting) - Compact padding (4px\u00d76px)</p>"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#5-customizable-footer","title":"5. Customizable Footer","text":"<p>Footer Text: Optional footer message (e.g., \"Thank you for volunteering!\")</p> <p>Source: <code>MapSettings.walkSheetFooter</code></p>"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#6-print-optimization","title":"6. Print Optimization","text":"<p>CSS @media print Rules: - Hides everything except <code>.walk-sheet-print</code> 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 (<code>no-print</code> class)</p> <p>Print Trigger: \"Print\" button in page header (calls <code>window.print()</code>)</p>"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#user-workflow","title":"User Workflow","text":""},{"location":"v2/frontend/pages/admin/walk-sheet-page/#configuring-walk-sheet-settings","title":"Configuring Walk Sheet Settings","text":"<ol> <li>Navigate to Map Settings:</li> <li>Click \"Map\" \u2192 \"Settings\" in sidebar</li> <li> <p>Scroll to \"Walk Sheet Configuration\" section</p> </li> <li> <p>Set Walk Sheet Title:</p> </li> <li>Enter title (e.g., \"Volunteer Canvassing Walk Sheet\")</li> <li> <p>This appears as main heading on printed sheet</p> </li> <li> <p>Set Walk Sheet Subtitle (Optional):</p> </li> <li>Enter subtitle (e.g., \"Ward 5 - Downtown District\")</li> <li> <p>Appears below title in smaller font</p> </li> <li> <p>Configure QR Codes (Up to 3):</p> </li> <li>QR Code 1:<ul> <li>URL: Enter full URL to encode (e.g., <code>https://cmlite.org/responses/1</code>)</li> <li>Label: Enter descriptive label (e.g., \"Submit Response\")</li> </ul> </li> <li>QR Code 2: (Optional)<ul> <li>URL + Label</li> </ul> </li> <li>QR Code 3: (Optional)<ul> <li>URL + Label</li> </ul> </li> <li> <p>QR codes appear centered above contact table</p> </li> <li> <p>Set Footer Text (Optional):</p> </li> <li>Enter footer message (e.g., \"Thank you for your time!\")</li> <li> <p>Appears at bottom of printed sheet</p> </li> <li> <p>Save Settings:</p> </li> <li>Click \"Save\" button in Map Settings page</li> <li>Settings applied to all future walk sheets</li> </ol>"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#printing-a-walk-sheet","title":"Printing a Walk Sheet","text":"<ol> <li>Navigate to Walk Sheet:</li> <li>Click \"Map\" \u2192 \"Walk Sheet\" in sidebar</li> <li> <p>Page loads with preview of walk sheet</p> </li> <li> <p>Review Preview:</p> </li> <li>Check title, subtitle, QR codes</li> <li>Verify table has 12 rows</li> <li> <p>Confirm footer text appears</p> </li> <li> <p>Print Walk Sheet:</p> </li> <li>Click \"Print\" button in page header</li> <li>OR press <code>Ctrl+P</code> (Windows/Linux) or <code>Cmd+P</code> (Mac)</li> <li> <p>Browser print dialog opens</p> </li> <li> <p>Configure Print Settings:</p> </li> <li>Orientation: Portrait (recommended)</li> <li>Paper Size: Letter (8.5\" \u00d7 11\")</li> <li>Margins: Default or minimal</li> <li> <p>Background graphics: ON (to print table borders clearly)</p> </li> <li> <p>Print or Save PDF:</p> </li> <li>Click \"Print\" to send to printer</li> <li>OR select \"Save as PDF\" to create digital copy</li> </ol>"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#using-walk-sheet-during-canvassing","title":"Using Walk Sheet During Canvassing","text":"<ol> <li>Before Starting:</li> <li>Print walk sheet (1 per cut/area)</li> <li> <p>Fill in volunteer name, date, area/cut at top</p> </li> <li> <p>At Each Door:</p> </li> <li>Record contact information in next empty row:<ul> <li>Name</li> <li>Address / Unit (if multi-unit building)</li> <li>Email (if provided)</li> <li>Phone (if provided)</li> </ul> </li> <li>Circle support level (1-4)</li> <li>Circle sign interest (R/L) if applicable</li> <li> <p>Write notes (e.g., \"Call back after 6pm\", \"Not home\")</p> </li> <li> <p>Completing Walk Sheet:</p> </li> <li>Fill all 12 rows OR complete area</li> <li>Return walk sheet to campaign organizer</li> <li> <p>Organizer enters data into system via Admin GUI</p> </li> <li> <p>QR Code Usage:</p> </li> <li>Volunteers can scan QR codes with phone to:<ul> <li>Report their location/status</li> <li>Submit response directly to response wall</li> <li>Access campaign resources</li> </ul> </li> </ol>"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#component-breakdown","title":"Component Breakdown","text":""},{"location":"v2/frontend/pages/admin/walk-sheet-page/#main-component-structure","title":"Main Component Structure","text":"<pre><code>export default function WalkSheetPage() {\n const { setPageHeader } = useOutletContext<AppOutletContext>();\n const { message } = App.useApp();\n const [settings, setSettings] = useState<MapSettings | null>(null);\n const [loading, setLoading] = useState(true);\n\n // Set page header with Print button\n useEffect(() => {\n setPageHeader({\n title: 'Walk Sheet',\n actions: (\n <Button icon={<PrinterOutlined />} onClick={() => window.print()}>\n Print\n </Button>\n ),\n });\n return () => setPageHeader(null);\n }, [setPageHeader]);\n\n // Load map settings (for walk sheet config)\n useEffect(() => {\n api.get('/map/settings')\n .then(({ data }) => setSettings(data))\n .catch(() => message.error('Failed to load settings'))\n .finally(() => setLoading(false));\n }, []);\n\n if (loading) {\n return <Spin size=\"large\" />;\n }\n\n // Filter QR codes (only include if URL provided)\n const qrCodes = [\n { url: settings?.qrCode1Url, label: settings?.qrCode1Label },\n { url: settings?.qrCode2Url, label: settings?.qrCode2Label },\n { url: settings?.qrCode3Url, label: settings?.qrCode3Label },\n ].filter((q) => q.url);\n\n // Generate 12 empty rows\n const rows = Array.from({ length: 12 }, (_, i) => i);\n\n return (\n <>\n <style>{/* Print CSS rules */}</style>\n\n <div className=\"walk-sheet-print\">\n {/* Header */}\n <Title level={3}>{settings?.walkSheetTitle || 'Walk Sheet'}</Title>\n {settings?.walkSheetSubtitle && <Text>{settings.walkSheetSubtitle}</Text>}\n\n {/* QR Codes */}\n {qrCodes.map((qr) => (\n <img src={`/api/qr?text=${qr.url}&size=100`} alt={qr.label} />\n ))}\n\n {/* Volunteer Info */}\n <div>\n <Text strong>Volunteer: </Text><span className=\"underline\" />\n <Text strong>Date: </Text><span className=\"underline\" />\n <Text strong>Area/Cut: </Text><span className=\"underline\" />\n </div>\n\n {/* Contact Table */}\n <table>\n <thead>\n <tr>\n <th>#</th>\n <th>Name</th>\n <th>Address / Unit</th>\n <th>Email</th>\n <th>Phone</th>\n <th>Support</th>\n <th>Sign</th>\n <th>Notes</th>\n </tr>\n </thead>\n <tbody>\n {rows.map((i) => (\n <tr key={i}>\n <td>{i + 1}</td>\n <td>&nbsp;</td> {/* Blank cells for handwriting */}\n {/* ... more blank cells ... */}\n <td> {/* Support circles */}\n <span className=\"support-circle\">1</span>\n <span className=\"support-circle\">2</span>\n <span className=\"support-circle\">3</span>\n <span className=\"support-circle\">4</span>\n </td>\n <td> {/* Sign circles */}\n <span className=\"support-circle\">R</span>\n <span className=\"support-circle\">L</span>\n </td>\n <td>&nbsp;</td>\n </tr>\n ))}\n </tbody>\n </table>\n\n {/* Footer */}\n {settings?.walkSheetFooter && <Text>{settings.walkSheetFooter}</Text>}\n </div>\n </>\n );\n}\n</code></pre>"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#ant-design-components-used","title":"Ant Design Components Used","text":"<ol> <li>Button - Print button in page header</li> <li>Typography.Title - Walk sheet main heading</li> <li>Typography.Text - Subtitle, labels, footer text</li> <li>Spin - Loading indicator while settings fetch</li> <li>App.useApp() - Toast message for errors</li> </ol>"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#print-css-styling","title":"Print CSS Styling","text":"<pre><code><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</code></pre> <p>Key Print Rules: - <code>visibility: hidden</code> on all elements except <code>.walk-sheet-print</code> - 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)</p>"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#state-management","title":"State Management","text":""},{"location":"v2/frontend/pages/admin/walk-sheet-page/#local-component-state-usestate","title":"Local Component State (useState)","text":"<p>No Zustand stores used - All state managed locally with React hooks.</p> <pre><code>// Map settings state (loaded from API)\nconst [settings, setSettings] = useState<MapSettings | null>(null);\n\n// Loading state\nconst [loading, setLoading] = useState(true);\n</code></pre>"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#state-flow","title":"State Flow","text":"<ol> <li>Component Mounts:</li> <li><code>loadSettings()</code> called in <code>useEffect</code></li> <li>Fetches map settings via <code>GET /api/map/settings</code></li> <li>Sets <code>settings</code> state</li> <li> <p>Sets <code>loading</code> to <code>false</code></p> </li> <li> <p>Settings Loaded:</p> </li> <li>Extracts walk sheet configuration:<ul> <li><code>walkSheetTitle</code>, <code>walkSheetSubtitle</code>, <code>walkSheetFooter</code></li> <li><code>qrCode1Url/Label</code>, <code>qrCode2Url/Label</code>, <code>qrCode3Url/Label</code></li> </ul> </li> <li>Filters QR codes (only include if URL provided)</li> <li> <p>Renders walk sheet with settings</p> </li> <li> <p>User Clicks Print:</p> </li> <li><code>window.print()</code> called</li> <li>Browser opens print dialog</li> <li>Print CSS rules activate</li> <li>Walk sheet rendered in print layout</li> </ol>"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#api-integration","title":"API Integration","text":""},{"location":"v2/frontend/pages/admin/walk-sheet-page/#endpoints-used","title":"Endpoints Used","text":"<ol> <li>GET /api/map/settings - Fetch map settings (including walk sheet config)</li> <li>GET /api/qr - Generate QR code PNG (public endpoint, no auth)</li> </ol>"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#api-client","title":"API Client","text":"<pre><code>import { api } from '@/lib/api';\n\n// All authenticated requests use API client with automatic token refresh\n</code></pre>"},{"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":"<pre><code>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</code></pre> <p>Response Format: <pre><code>{\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</code></pre></p>"},{"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":"<pre><code>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</code></pre> <p>Endpoint: <code>GET /api/qr?text={url}&size={pixels}</code></p> <p>Query Parameters: - <code>text</code> (required): URL or text to encode in QR code - <code>size</code> (optional): QR code pixel size (default: 200)</p> <p>Response: PNG image (binary data)</p> <p>Example URL: <pre><code>http://api.cmlite.org/api/qr?text=https%3A%2F%2Fcmlite.org%2Fresponses%2F1&size=100\n</code></pre></p> <p>Note: This endpoint is public (no authentication required) to allow QR codes to be scanned by anyone.</p>"},{"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":"<pre><code>import { useEffect, useState } from 'react';\nimport { Button, Typography, Spin, App } from 'antd';\nimport { PrinterOutlined } from '@ant-design/icons';\nimport { useOutletContext } from 'react-router-dom';\nimport { api } from '@/lib/api';\nimport type { AppOutletContext } from '@/components/AppLayout';\nimport type { MapSettings } from '@/types/api';\n\nconst { Title, Text } = Typography;\nconst API_BASE = import.meta.env.VITE_API_URL || '';\n\nexport default function WalkSheetPage() {\n const { setPageHeader } = useOutletContext<AppOutletContext>();\n const { message } = App.useApp();\n const [settings, setSettings] = useState<MapSettings | null>(null);\n const [loading, setLoading] = useState(true);\n\n // Set page header with Print button\n useEffect(() => {\n setPageHeader({\n title: 'Walk Sheet',\n actions: (\n <Button icon={<PrinterOutlined />} onClick={() => window.print()}>\n Print\n </Button>\n ),\n });\n return () => setPageHeader(null);\n }, [setPageHeader]);\n\n // Load settings\n useEffect(() => {\n api.get('/map/settings')\n .then(({ data }) => setSettings(data))\n .catch(() => message.error('Failed to load settings'))\n .finally(() => setLoading(false));\n }, [message]);\n\n if (loading) {\n return <div style={{ textAlign: 'center', padding: 48 }}><Spin size=\"large\" /></div>;\n }\n\n // Filter QR codes (only show if URL provided)\n const qrCodes = [\n { url: settings?.qrCode1Url, label: settings?.qrCode1Label },\n { url: settings?.qrCode2Url, label: settings?.qrCode2Label },\n { url: settings?.qrCode3Url, label: settings?.qrCode3Label },\n ].filter((q) => q.url);\n\n // Generate 12 empty rows\n const rows = Array.from({ length: 12 }, (_, i) => i);\n\n return (\n <>\n <style>{`\n @media print {\n body * { visibility: hidden; }\n .walk-sheet-print, .walk-sheet-print * { visibility: visible; }\n .walk-sheet-print {\n position: absolute;\n left: 0;\n top: 0;\n width: 100%;\n font-size: 11px;\n }\n .walk-sheet-print .no-print { display: none !important; }\n }\n .walk-sheet-print table {\n width: 100%;\n border-collapse: collapse;\n }\n .walk-sheet-print th,\n .walk-sheet-print td {\n border: 1px solid #555;\n padding: 4px 6px;\n text-align: left;\n font-size: 11px;\n }\n .walk-sheet-print th {\n background: rgba(255,255,255,0.05);\n font-weight: 600;\n }\n .support-circle {\n display: inline-block;\n width: 16px;\n height: 16px;\n border: 1.5px solid rgba(255,255,255,0.4);\n border-radius: 50%;\n text-align: center;\n line-height: 14px;\n font-size: 9px;\n margin-right: 2px;\n }\n `}</style>\n\n <div className=\"walk-sheet-print\">\n {/* Header */}\n <div style={{ textAlign: 'center', marginBottom: 16 }}>\n <Title level={3} style={{ marginBottom: 2 }}>\n {settings?.walkSheetTitle || 'Walk Sheet'}\n </Title>\n {settings?.walkSheetSubtitle && (\n <Text type=\"secondary\" style={{ fontSize: 14 }}>{settings.walkSheetSubtitle}</Text>\n )}\n </div>\n\n {/* QR Codes */}\n {qrCodes.length > 0 && (\n <div style={{ display: 'flex', justifyContent: 'center', gap: 24, marginBottom: 16 }}>\n {qrCodes.map((qr, idx) => (\n <div key={idx} style={{ textAlign: 'center' }}>\n <img\n src={`${API_BASE}/api/qr?text=${encodeURIComponent(qr.url!)}&size=100`}\n alt={qr.label || 'QR'}\n style={{ width: 80, height: 80 }}\n />\n {qr.label && (\n <div style={{ fontSize: 10, marginTop: 2 }}>\n <Text type=\"secondary\">{qr.label}</Text>\n </div>\n )}\n </div>\n ))}\n </div>\n )}\n\n {/* Volunteer info line */}\n <div style={{ display: 'flex', gap: 16, marginBottom: 12 }}>\n <div style={{ flex: 1 }}>\n <Text strong>Volunteer: </Text>\n <span style={{ borderBottom: '1px solid rgba(255,255,255,0.3)', display: 'inline-block', width: 200 }}>&nbsp;</span>\n </div>\n <div>\n <Text strong>Date: </Text>\n <span style={{ borderBottom: '1px solid rgba(255,255,255,0.3)', display: 'inline-block', width: 120 }}>&nbsp;</span>\n </div>\n <div>\n <Text strong>Area/Cut: </Text>\n <span style={{ borderBottom: '1px solid rgba(255,255,255,0.3)', display: 'inline-block', width: 120 }}>&nbsp;</span>\n </div>\n </div>\n\n {/* Contact table */}\n <table>\n <thead>\n <tr>\n <th style={{ width: 20 }}>#</th>\n <th>Name</th>\n <th>Address / Unit</th>\n <th>Email</th>\n <th>Phone</th>\n <th style={{ width: 70 }}>Support</th>\n <th style={{ width: 50 }}>Sign</th>\n <th>Notes</th>\n </tr>\n </thead>\n <tbody>\n {rows.map((i) => (\n <tr key={i}>\n <td style={{ textAlign: 'center' }}>{i + 1}</td>\n <td style={{ height: 28 }}>&nbsp;</td>\n <td>&nbsp;</td>\n <td>&nbsp;</td>\n <td>&nbsp;</td>\n <td style={{ textAlign: 'center' }}>\n <span className=\"support-circle\">1</span>\n <span className=\"support-circle\">2</span>\n <span className=\"support-circle\">3</span>\n <span className=\"support-circle\">4</span>\n </td>\n <td style={{ textAlign: 'center' }}>\n <span className=\"support-circle\">R</span>\n <span className=\"support-circle\">L</span>\n </td>\n <td>&nbsp;</td>\n </tr>\n ))}\n </tbody>\n </table>\n\n {/* Footer */}\n {settings?.walkSheetFooter && (\n <div style={{ marginTop: 16, fontSize: 11, textAlign: 'center' }}>\n <Text type=\"secondary\">{settings.walkSheetFooter}</Text>\n </div>\n )}\n </div>\n </>\n );\n}\n</code></pre>"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#qr-code-generation-pattern","title":"QR Code Generation Pattern","text":"<pre><code>// 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</code></pre> <p>Important: Always use <code>encodeURIComponent()</code> to escape special characters in URL.</p>"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#print-trigger-pattern","title":"Print Trigger Pattern","text":"<pre><code>// 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</code></pre>"},{"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":"<p>Settings loaded once on mount:</p> <pre><code>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</code></pre> <p>Benefit: Minimizes API requests. Settings cached in state until page unmount.</p>"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#2-inline-qr-code-images","title":"2. Inline QR Code Images","text":"<p>QR codes embedded as <code><img></code> tags with <code>src</code> pointing to QR API:</p> <pre><code><img src={`${API_BASE}/api/qr?text=${url}&size=100`} />\n</code></pre> <p>Benefit: Browser caches QR code images. No JavaScript overhead for QR generation.</p>"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#3-static-row-generation","title":"3. Static Row Generation","text":"<p>12 rows generated once with <code>Array.from()</code>:</p> <pre><code>const rows = Array.from({ length: 12 }, (_, i) => i);\n\n{rows.map((i) => <tr key={i}>...</tr>)}\n</code></pre> <p>Benefit: Simple, performant array mapping. No state updates or re-renders.</p>"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#4-print-css-optimization","title":"4. Print CSS Optimization","text":"<p>Print rules use <code>visibility: hidden</code> instead of <code>display: none</code>:</p> <pre><code>body * { visibility: hidden; }\n.walk-sheet-print, .walk-sheet-print * { visibility: visible; }\n</code></pre> <p>Benefit: Preserves layout and spacing. Prevents reflow during print preparation.</p>"},{"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":"<p>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</p> <p>Rationale: Physical walk sheets used by volunteers in field, printed from desktop computers before canvassing.</p>"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#print-layout","title":"Print Layout","text":"<pre><code>@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</code></pre> <p>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)</p>"},{"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":"<p>Walk sheet is physical form, not interactive UI. Accessibility considerations minimal:</p> <ol> <li>Semantic HTML:</li> <li><code><table></code> for contact grid</li> <li><code><th></code> for column headers</li> <li> <p><code><td></code> for data cells</p> </li> <li> <p>Print Button:</p> </li> <li>Keyboard accessible (Tab + Enter)</li> <li>Icon + text label (\"Print\")</li> <li> <p>ARIA label implicit from button text</p> </li> <li> <p>Screen Reader Support:</p> </li> <li>Table headers announced for each column</li> <li>Row numbers read in sequence</li> <li>QR code <code>alt</code> attributes describe purpose</li> </ol> <p>Note: Once printed, walk sheet relies on visual cues (circles, lines, table borders) for volunteers to fill in by hand.</p>"},{"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":"<p>Symptoms: - Walk sheet loads but QR code section is empty - Expected 1-3 QR codes but none visible</p> <p>Causes: 1. No QR code URLs configured in Map Settings 2. QR API endpoint not responding 3. CORS issues blocking QR images</p> <p>Solutions:</p> <ol> <li>Check Map Settings:</li> <li>Navigate to \"Map\" \u2192 \"Settings\"</li> <li>Scroll to \"Walk Sheet Configuration\"</li> <li>Verify QR Code URLs filled in:<ul> <li><code>qrCode1Url</code>, <code>qrCode2Url</code>, <code>qrCode3Url</code></li> </ul> </li> <li> <p>At least one URL must be provided</p> </li> <li> <p>Test QR API endpoint: <pre><code>curl http://localhost:4000/api/qr?text=https://example.com&size=100 --output test-qr.png\n</code></pre></p> </li> <li>Should return PNG image</li> <li> <p>Open <code>test-qr.png</code> to verify QR code generated</p> </li> <li> <p>Check browser console:</p> </li> <li>Open DevTools (F12)</li> <li>Go to Network tab</li> <li>Refresh walk sheet page</li> <li>Look for <code>/api/qr?text=...</code> requests</li> <li>Check status codes (should be 200)</li> <li>If 404, QR API route not registered</li> <li> <p>If CORS error, check nginx CORS headers</p> </li> <li> <p>Verify API base URL:</p> </li> <li>Check <code>.env</code> file: <code>VITE_API_URL=http://localhost:4000</code></li> <li>Restart admin dev server after changing <code>.env</code></li> </ol>"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#problem-walk-sheet-doesnt-print-correctly","title":"Problem: Walk Sheet Doesn't Print Correctly","text":"<p>Symptoms: - Print preview shows blank page - Print preview shows partial content - Table borders not visible when printed</p> <p>Causes: 1. Browser print settings incorrect 2. Background graphics disabled 3. Print CSS not applying 4. Page margins too large</p> <p>Solutions:</p> <ol> <li>Enable background graphics:</li> <li>In print dialog, check \"Background graphics\" option</li> <li> <p>This ensures table borders and support circles print</p> </li> <li> <p>Adjust page margins:</p> </li> <li>In print dialog, set margins to \"Default\" or \"Minimal\"</li> <li> <p>Too large margins can cut off content</p> </li> <li> <p>Verify print CSS:</p> </li> <li>View print preview (Ctrl+P or Cmd+P)</li> <li>Check that only walk sheet visible (no sidebar, no header)</li> <li> <p>If other elements visible, print CSS not applying</p> </li> <li> <p>Check browser zoom:</p> </li> <li>Reset zoom to 100% (Ctrl+0 or Cmd+0)</li> <li> <p>Print preview at wrong zoom can cause layout issues</p> </li> <li> <p>Try different browser:</p> </li> <li>Chrome, Firefox, and Edge have different print engines</li> <li>If one fails, try another</li> </ol>"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#problem-support-circles-too-small-when-printed","title":"Problem: Support Circles Too Small When Printed","text":"<p>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</p> <p>Causes: 1. Print scaling set to \"Fit to page\" (shrinks content) 2. Circle size optimized for screen, not print</p> <p>Solutions:</p> <ol> <li>Check print scaling:</li> <li>In print dialog, set scale to \"100%\" (not \"Fit to page\")</li> <li> <p>\"Fit to page\" shrinks content to fit, making circles smaller</p> </li> <li> <p>Adjust circle size in code:</p> </li> <li>Edit <code>WalkSheetPage.tsx</code></li> <li>Increase <code>.support-circle</code> dimensions: <pre><code>.support-circle {\n width: 20px; /* Was 16px */\n height: 20px; /* Was 16px */\n font-size: 11px; /* Was 9px */\n}\n</code></pre></li> <li>Save and refresh page</li> <li> <p>Print again to test</p> </li> <li> <p>Increase row height:</p> </li> <li>More vertical space gives volunteers more room to mark: <pre><code><td style={{ height: 32 }}>&nbsp;</td> {/* Was 28px */}\n</code></pre></li> </ol>"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#problem-footer-text-cut-off","title":"Problem: Footer Text Cut Off","text":"<p>Symptoms: - Footer message not visible in print preview - Footer appears on screen but missing when printed</p> <p>Causes: 1. Page margins cutting off bottom content 2. Footer outside printable area 3. Content too tall for one page</p> <p>Causes: 1. Footer outside printable area due to margins 2. Content too tall to fit on one page</p> <p>Solutions:</p> <ol> <li>Reduce page margins:</li> <li>In print dialog, set margins to \"Minimal\" (0.25\")</li> <li> <p>This gives more vertical space for content</p> </li> <li> <p>Reduce font sizes:</p> </li> <li>Edit print CSS: <pre><code>@media print {\n .walk-sheet-print { font-size: 10px !important; } /* Was 11px */\n .walk-sheet-print table { font-size: 8px !important; } /* Was 9px */\n}\n</code></pre></li> <li> <p>Smaller fonts = more content fits on page</p> </li> <li> <p>Reduce number of rows:</p> </li> <li> <p>If footer consistently cut off, reduce rows from 12 to 10: <pre><code>const rows = Array.from({ length: 10 }, (_, i) => i); // Was 12\n</code></pre></p> </li> <li> <p>Remove footer (temporary):</p> </li> <li>If footer not essential, remove from Map Settings:<ul> <li>Navigate to \"Map\" \u2192 \"Settings\"</li> <li>Clear \"Walk Sheet Footer\" field</li> <li>Save settings</li> </ul> </li> </ol>"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#problem-titlesubtitle-not-appearing","title":"Problem: Title/Subtitle Not Appearing","text":"<p>Symptoms: - Walk sheet header shows default \"Walk Sheet\" instead of custom title - Subtitle missing entirely</p> <p>Causes: 1. Map Settings not saved correctly 2. API not returning settings 3. Settings state null/undefined</p> <p>Solutions:</p> <ol> <li>Verify Map Settings saved:</li> <li>Navigate to \"Map\" \u2192 \"Settings\"</li> <li>Check \"Walk Sheet Title\" and \"Walk Sheet Subtitle\" fields</li> <li>Re-enter values if blank</li> <li>Click \"Save\" button</li> <li> <p>Success message should appear</p> </li> <li> <p>Check browser console:</p> </li> <li>Open DevTools (F12)</li> <li>Go to Console tab</li> <li>Look for error messages</li> <li> <p>If \"Failed to load settings\", API request failed</p> </li> <li> <p>Check Network tab:</p> </li> <li>Open DevTools (F12)</li> <li>Go to Network tab</li> <li>Refresh walk sheet page</li> <li>Look for <code>GET /api/map/settings</code> request</li> <li>Check Response tab for settings data: <pre><code>{\n \"walkSheetTitle\": \"...\",\n \"walkSheetSubtitle\": \"...\"\n}\n</code></pre></li> <li> <p>If fields null/missing, settings not saved in database</p> </li> <li> <p>Check database: <pre><code>docker compose exec api npx prisma studio\n# Navigate to MapSettings table\n# Verify walkSheetTitle and walkSheetSubtitle columns populated\n</code></pre></p> </li> </ol>"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/frontend/pages/admin/walk-sheet-page/#backend-documentation","title":"Backend Documentation","text":"<ul> <li>Map Settings Module - MapSettings CRUD API</li> <li>QR Routes - QR code generation endpoint</li> </ul>"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#frontend-documentation","title":"Frontend Documentation","text":"<ul> <li>MapSettingsPage - Configure walk sheet title, QR codes, footer</li> <li>CutExportPage - Printable cut location report (related printable page)</li> </ul>"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#feature-documentation","title":"Feature Documentation","text":"<ul> <li>Canvassing System - Complete volunteer canvassing workflow</li> <li>Walk Sheet Workflow - Physical walk sheet \u2192 digital data entry</li> </ul>"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#api-documentation","title":"API Documentation","text":"<ul> <li>GET /api/map/settings - Fetch map settings</li> <li>GET /api/qr - Generate QR code PNG</li> </ul>"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#user-guides","title":"User Guides","text":"<ul> <li>Volunteer Guide - Using walk sheets during canvassing</li> <li>Campaign Organizer Guide - Walk sheet configuration and printing</li> </ul>"},{"location":"v2/frontend/pages/admin/walk-sheet-page/#deployment-documentation","title":"Deployment Documentation","text":"<ul> <li>QR Service Setup - Mini QR Docker container</li> <li>Printing Best Practices - Print server configuration for campaign offices</li> </ul>"},{"location":"v2/frontend/pages/public/","title":"Public Pages","text":"<p>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.</p>"},{"location":"v2/frontend/pages/public/#route-context","title":"Route Context","text":"<ul> <li>Prefix: Various (<code>/campaigns</code>, <code>/map</code>, <code>/shifts</code>, <code>/p/:slug</code>, <code>/media</code>)</li> <li>Layout: PublicLayout (dark theme)</li> <li>Auth Required: No</li> <li>Theme: Dark blue/teal (<code>#0d1b2a</code> background)</li> </ul>"},{"location":"v2/frontend/pages/public/#campaign-pages","title":"Campaign Pages","text":""},{"location":"v2/frontend/pages/public/#campaigns-list-page","title":"Campaigns List Page","text":"<p>Route: <code>/campaigns</code></p> <p>Featured campaign listing:</p> <ul> <li>Hero section with call-to-action</li> <li>Featured campaigns grid</li> <li>Campaign cards with images</li> <li>Search and filter (future)</li> <li>Responsive grid layout</li> </ul> <p>Features: - Public campaign discovery - Featured campaigns first - Card-based design - Mobile responsive</p>"},{"location":"v2/frontend/pages/public/#campaign-page","title":"Campaign Page","text":"<p>Route: <code>/campaigns/:id</code></p> <p>Campaign detail and action page:</p> <ul> <li>Campaign description</li> <li>Target representatives lookup</li> <li>Email form with templates</li> <li>Progress tracking</li> <li>Social sharing</li> </ul> <p>Features: - Postal code \u2192 representative lookup - Email to representatives - Form validation - Success confirmation - Response submission link</p>"},{"location":"v2/frontend/pages/public/#response-wall-page","title":"Response Wall Page","text":"<p>Route: <code>/responses/:campaignId</code></p> <p>Public response submissions and viewing:</p> <ul> <li>Response submission form</li> <li>Email verification flow</li> <li>Response list (verified only)</li> <li>Upvoting system</li> <li>Sorting options</li> </ul> <p>Features: - Submit responses anonymously - Email verification required - Upvote responses - Sort by newest/popular - Responsive cards</p>"},{"location":"v2/frontend/pages/public/#map-location-pages","title":"Map & Location Pages","text":""},{"location":"v2/frontend/pages/public/#map-page","title":"Map Page","text":"<p>Route: <code>/map</code></p> <p>Public interactive map:</p> <ul> <li>Leaflet map with locations</li> <li>Color-coded markers by status</li> <li>Geographic cuts overlay</li> <li>Cut visibility controls</li> <li>Geolocate button</li> <li>Fullscreen mode</li> <li>Map legend</li> </ul> <p>Features: - OpenStreetMap tiles - Custom markers - Polygon overlays - Popup information - Mobile responsive</p>"},{"location":"v2/frontend/pages/public/#shifts-page","title":"Shifts Page","text":"<p>Route: <code>/shifts</code></p> <p>Public shift signup:</p> <ul> <li>Shift cards by date</li> <li>Cut information</li> <li>Signup modal</li> <li>Temp user creation</li> <li>Email confirmation</li> </ul> <p>Features: - Filter by date/cut - Quick signup flow - Anonymous signups (creates TEMP user) - Email notifications - Mobile responsive</p>"},{"location":"v2/frontend/pages/public/#content-pages","title":"Content Pages","text":""},{"location":"v2/frontend/pages/public/#landing-page","title":"Landing Page","text":"<p>Route: <code>/p/:slug</code></p> <p>Rendered landing pages:</p> <ul> <li>Custom HTML/CSS content</li> <li>GrapesJS block rendering</li> <li>Responsive design</li> <li>SEO metadata</li> <li>Custom scripts support</li> </ul> <p>Features: - Dynamic content from database - Custom styling - Block-based layout - Published pages only</p>"},{"location":"v2/frontend/pages/public/#media-pages","title":"Media Pages","text":""},{"location":"v2/frontend/pages/public/#media-gallery-page","title":"Media Gallery Page","text":"<p>Route: <code>/media</code></p> <p>Public video gallery:</p> <ul> <li>Shared videos grid</li> <li>Category filtering</li> <li>Search functionality</li> <li>Reaction system (6 emojis)</li> <li>Video cards with thumbnails</li> </ul> <p>Features: - Public videos only (unlocked + shared) - Responsive grid - Click to view details - Emoji reactions - Mobile responsive</p>"},{"location":"v2/frontend/pages/public/#media-viewer-page","title":"Media Viewer Page","text":"<p>Route: <code>/media/:id</code></p> <p>Video detail page:</p> <ul> <li>Video player</li> <li>Title and description</li> <li>Reaction buttons</li> <li>Related videos</li> <li>Share options</li> </ul> <p>Features: - HTML5 video player - Reaction tracking - Social sharing - Mobile responsive</p>"},{"location":"v2/frontend/pages/public/#public-page-count","title":"Public Page Count","text":"<p>Total: 8 public pages</p>"},{"location":"v2/frontend/pages/public/#common-features","title":"Common Features","text":"<p>Public pages share:</p> <ul> <li>Dark Theme - Blue/teal color scheme (#0d1b2a)</li> <li>No Authentication - Open access</li> <li>Responsive Design - Mobile-first approach</li> <li>Grid Breakpoints - Uses <code>Grid.useBreakpoint()</code></li> <li>Loading States - Spinners and skeletons</li> <li>Error Handling - User-friendly messages</li> <li>SEO Friendly - Meta tags, semantic HTML</li> </ul>"},{"location":"v2/frontend/pages/public/#theme-colors","title":"Theme Colors","text":"<pre><code>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</code></pre>"},{"location":"v2/frontend/pages/public/#layout-structure","title":"Layout Structure","text":"<p>Public pages use PublicLayout which provides:</p> <ul> <li>Header</li> <li>Logo/branding</li> <li>Navigation links</li> <li> <p>Login button (when not authenticated)</p> </li> <li> <p>Content Area</p> </li> <li>Full-width container</li> <li>Responsive padding</li> <li> <p>Dark theme styling</p> </li> <li> <p>Footer</p> </li> <li>Contact links</li> <li>About information</li> <li>Social media</li> <li>Copyright</li> </ul>"},{"location":"v2/frontend/pages/public/#mobile-responsiveness","title":"Mobile Responsiveness","text":"<p>Public pages are optimized for mobile:</p> <ul> <li>Touch-friendly controls</li> <li>Responsive grids</li> <li>Mobile navigation</li> <li>Optimized forms</li> <li>Fast loading</li> </ul>"},{"location":"v2/frontend/pages/public/#api-integration","title":"API Integration","text":"<p>Public pages use direct axios (no auth interceptor):</p> <pre><code>import axios from 'axios';\n\nconst response = await axios.get(\n `${import.meta.env.VITE_API_URL}/api/campaigns/public`\n);\n</code></pre> <p>Admin pages use authenticated <code>api</code> client from <code>lib/api.ts</code>.</p>"},{"location":"v2/frontend/pages/public/#form-validation","title":"Form Validation","text":"<p>Public forms use Zod validation:</p> <pre><code>const emailSchema = z.object({\n email: z.string().email(),\n message: z.string().min(10),\n});\n</code></pre>"},{"location":"v2/frontend/pages/public/#related-documentation","title":"Related Documentation","text":"<ul> <li>Frontend Pages Overview</li> <li>Admin Pages</li> <li>Volunteer Pages</li> <li>Public Layout</li> <li>Backend Public Routes</li> <li>Campaign Features</li> <li>Map Features</li> </ul>"},{"location":"v2/frontend/pages/public/campaign-page/","title":"Campaign Detail Page","text":""},{"location":"v2/frontend/pages/public/campaign-page/#overview","title":"Overview","text":"<p>File Path: <code>admin/src/pages/public/CampaignPage.tsx</code> (613 lines)</p> <p>Route: <code>/campaigns/:id</code></p> <p>Role Requirements: Public access (no authentication required)</p> <p>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.</p> <p>Key Features:</p> <ul> <li>3-step guided process: Info \u2192 Reps \u2192 Send</li> <li>Step indicator with clickable navigation</li> <li>Hero section with cover photo and real-time statistics</li> <li>Postal code-based representative lookup with government level filtering</li> <li>Dual email sending options: SMTP (tracked) and Email App (mailto)</li> <li>Live email preview with optional editing</li> <li>Response wall integration with CTA button</li> <li>Social sharing buttons</li> <li>Dark blue/teal theme consistent with public pages</li> <li>Mobile-responsive with hamburger navigation</li> </ul> <p>Layout: Uses <code>PublicLayout</code> component with dark theme</p>"},{"location":"v2/frontend/pages/public/campaign-page/#features","title":"Features","text":""},{"location":"v2/frontend/pages/public/campaign-page/#1-step-based-workflow","title":"1. Step-Based Workflow","text":"<p>Three-step process guides users through advocacy action:</p> <ul> <li>Step 1: Campaign Info - Overview, description, statistics</li> <li>Step 2: Your Representatives - Postal code lookup and rep selection</li> <li>Step 3: Send Your Message - Email composition and sending</li> </ul> <p>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</p> <p>Navigation Controls: - \"Previous\" button (disabled on step 1) - \"Next\" button (changes to \"Send Emails\" on step 3) - \"Back to Campaigns\" link in header</p>"},{"location":"v2/frontend/pages/public/campaign-page/#2-hero-section","title":"2. Hero Section","text":"<p>Prominent campaign header with visual branding:</p> <ul> <li>Cover Photo: Full-width image (400px desktop, 250px mobile) with gradient overlay</li> <li>Fallback Gradient: Purple-to-blue when no cover photo</li> <li>Title Overlay: Campaign title in white text over semi-transparent background</li> <li>Statistics Circles: Floating overlay with two metrics</li> <li>Emails Sent count (blue circle)</li> <li>Responses count (green circle)</li> <li>Positioning: Absolute positioned in top-right of hero</li> <li>Responsive: Circles stack vertically on mobile</li> </ul>"},{"location":"v2/frontend/pages/public/campaign-page/#3-representative-lookup","title":"3. Representative Lookup","text":"<p>Government-level aware representative discovery:</p> <ul> <li>Postal Code Input: Large text input with search icon</li> <li>Loading State: Spinner in input suffix during lookup</li> <li>Government Level Filtering: Shows only reps matching campaign targets</li> <li>Federal campaigns \u2192 Federal MPs only</li> <li>Provincial campaigns \u2192 Provincial MPPs/MLAs only</li> <li>Municipal campaigns \u2192 Municipal councillors only</li> <li>Multi-level campaigns \u2192 All applicable reps</li> <li>Representative Cards: Grid layout with detailed info</li> <li>Circular photo (120px diameter)</li> <li>Name and title</li> <li>District/riding</li> <li>Party badge</li> <li>Email address (copyable)</li> <li>Phone number</li> <li>Office address</li> <li>Send button (primary CTA)</li> <li>Email App button (secondary CTA)</li> <li>Auto-advance: Automatically proceeds to step 3 when reps loaded</li> <li>No Results State: Helpful message suggesting alternate contact methods</li> </ul>"},{"location":"v2/frontend/pages/public/campaign-page/#4-email-sending-system","title":"4. Email Sending System","text":"<p>Dual-mode email delivery with tracking:</p> <p>SMTP Send (Tracked): - Sends via backend BullMQ queue - Tracked in <code>CampaignEmail</code> table - Statistics reflected in dashboard - Requires valid email address - Shows success confirmation - Increments \"Emails Sent\" counter</p> <p>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</p> <p>Email Preview: - Live rendering of email template - Substitutes <code>{name}</code>, <code>{email}</code>, <code>{postalCode}</code> placeholders - Shows subject line - Read-only by default - Optional editing mode (if <code>allowEmailEditing=true</code>)</p>"},{"location":"v2/frontend/pages/public/campaign-page/#5-response-wall-integration","title":"5. Response Wall Integration","text":"<p>Campaign-specific response display:</p> <ul> <li>\"See What Others Are Saying\" Button: Links to response wall</li> <li>Response Count Badge: Shows total verified responses</li> <li>Conditional Display: Only shown if responses exist</li> <li>Navigation: Links to <code>/responses/:campaignId</code></li> </ul>"},{"location":"v2/frontend/pages/public/campaign-page/#6-social-sharing","title":"6. Social Sharing","text":"<p>ShareButtons component for campaign promotion:</p> <ul> <li>Platforms: X, Facebook, LinkedIn, Reddit, Email, Copy Link</li> <li>Share URL: Current campaign page URL</li> <li>Share Title: Campaign title</li> <li>Share Description: Campaign description (truncated to 200 chars)</li> <li>Positioning: Below main content, above footer</li> </ul>"},{"location":"v2/frontend/pages/public/campaign-page/#user-workflow","title":"User Workflow","text":""},{"location":"v2/frontend/pages/public/campaign-page/#complete-advocacy-flow","title":"Complete Advocacy Flow","text":"<ol> <li>User arrives at campaign page (via <code>/campaigns/:id</code>)</li> <li>Step 1 loads automatically showing campaign info</li> <li>User reads description and decides to take action</li> <li>User clicks \"Next\" to proceed to Step 2</li> <li>User enters postal code in \"Your Representatives\" section</li> <li>API lookup triggered on blur or Enter key</li> <li>Representatives filtered by government level</li> <li>Auto-advance to Step 3 when reps loaded</li> <li>User reviews email preview with personalized content</li> <li>User edits email (if allowed by campaign settings)</li> <li>User clicks \"Send\" button on rep card (SMTP option)<ul> <li>OR clicks \"Open in Email App\" (mailto option)</li> </ul> </li> <li>Backend creates CampaignEmail record and queues job</li> <li>Success message displays confirming email sent</li> <li>User repeats for additional representatives</li> <li>User views response wall (optional) to see others' activity</li> <li>User shares campaign on social media</li> </ol>"},{"location":"v2/frontend/pages/public/campaign-page/#representative-selection-flow","title":"Representative Selection Flow","text":"<p>Representative selection happens implicitly (no checkboxes):</p> <ol> <li>User clicks \"Send\" on specific rep card</li> <li>Email sent to that rep only</li> <li>User can send to multiple reps by clicking multiple cards</li> <li>Each send creates separate CampaignEmail record</li> <li>No bulk sending (encourages personalization)</li> </ol>"},{"location":"v2/frontend/pages/public/campaign-page/#error-recovery-flow","title":"Error Recovery Flow","text":"<p>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</p> <p>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</p> <p>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</p>"},{"location":"v2/frontend/pages/public/campaign-page/#component-structure","title":"Component Structure","text":"<pre><code>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</code></pre>"},{"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":"<pre><code>// 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</code></pre>"},{"location":"v2/frontend/pages/public/campaign-page/#derived-state","title":"Derived State","text":"<pre><code>// 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</code></pre>"},{"location":"v2/frontend/pages/public/campaign-page/#state-flow","title":"State Flow","text":"<ol> <li>Initial Load: <code>loading=true</code>, fetch campaign by ID</li> <li>Campaign Loaded: <code>setCampaign()</code>, <code>setLoading(false)</code></li> <li>User Enters Postal Code: <code>setPostalCode()</code> updates input</li> <li>Lookup Triggered: <code>setRepsLoading(true)</code>, fetch representatives</li> <li>Reps Loaded: <code>setRepresentatives()</code>, <code>setRepsLoading(false)</code>, auto-advance to step 3</li> <li>User Customizes Email: <code>setCustomEmailBody()</code> if editing allowed</li> <li>User Clicks Send: <code>setSendingTo(rep.email)</code>, post to API</li> <li>Send Complete: <code>setSendingTo(null)</code>, show success message, increment counter</li> </ol>"},{"location":"v2/frontend/pages/public/campaign-page/#api-integration","title":"API Integration","text":""},{"location":"v2/frontend/pages/public/campaign-page/#endpoints-used","title":"Endpoints Used","text":""},{"location":"v2/frontend/pages/public/campaign-page/#1-get-campaign-by-id","title":"1. Get Campaign by ID","text":"<pre><code>GET /api/public/campaigns/:id\n</code></pre> <p>Response: <pre><code>{\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</code></pre></p>"},{"location":"v2/frontend/pages/public/campaign-page/#2-lookup-representatives","title":"2. Lookup Representatives","text":"<pre><code>GET /api/public/representatives/lookup?postalCode=K1A0B1\n</code></pre> <p>Response: <pre><code>[\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</code></pre></p>"},{"location":"v2/frontend/pages/public/campaign-page/#3-send-campaign-email","title":"3. Send Campaign Email","text":"<pre><code>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</code></pre> <p>Response: <pre><code>{\n \"success\": true,\n \"emailId\": \"cm2def456\",\n \"message\": \"Email queued for sending\"\n}\n</code></pre></p>"},{"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":"<pre><code>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</code></pre>"},{"location":"v2/frontend/pages/public/campaign-page/#lookup-representatives","title":"Lookup Representatives","text":"<pre><code>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</code></pre>"},{"location":"v2/frontend/pages/public/campaign-page/#send-email","title":"Send Email","text":"<pre><code>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</code></pre>"},{"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":"<pre><code><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</code></pre>"},{"location":"v2/frontend/pages/public/campaign-page/#step-indicator","title":"Step Indicator","text":"<pre><code><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</code></pre>"},{"location":"v2/frontend/pages/public/campaign-page/#representative-cards-with-dual-send-options","title":"Representative Cards with Dual Send Options","text":"<pre><code><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</code></pre>"},{"location":"v2/frontend/pages/public/campaign-page/#email-preview-with-optional-editing","title":"Email Preview with Optional Editing","text":"<pre><code><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</code></pre>"},{"location":"v2/frontend/pages/public/campaign-page/#user-information-form","title":"User Information Form","text":"<pre><code><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</code></pre>"},{"location":"v2/frontend/pages/public/campaign-page/#response-wall-cta","title":"Response Wall CTA","text":"<pre><code>{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</code></pre>"},{"location":"v2/frontend/pages/public/campaign-page/#navigation-controls","title":"Navigation Controls","text":"<pre><code><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</code></pre>"},{"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":"<p>Uses <code>useMemo</code> to avoid re-computing on every render:</p> <pre><code>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</code></pre> <p>Benefit: Preview only recalculates when dependencies change, not on every keystroke.</p>"},{"location":"v2/frontend/pages/public/campaign-page/#2-auto-advance-after-lookup","title":"2. Auto-advance After Lookup","text":"<p>Automatically proceeds to step 3 when representatives loaded:</p> <pre><code>if (response.data.length > 0) {\n setCurrentStep(2); // Auto-advance\n message.success(`Found ${response.data.length} representative(s)`);\n}\n</code></pre> <p>Benefit: Reduces user clicks, smoother workflow.</p>"},{"location":"v2/frontend/pages/public/campaign-page/#3-optimistic-ui-updates","title":"3. Optimistic UI Updates","text":"<p>Updates email counter immediately after send (before API response):</p> <pre><code>message.success(`Email sent to ${rep.name}!`);\n\nsetCampaign(prev => prev ? {\n ...prev,\n emailsSentCount: prev.emailsSentCount + 1\n} : null);\n</code></pre> <p>Benefit: Instant feedback, perceived performance improvement.</p>"},{"location":"v2/frontend/pages/public/campaign-page/#4-conditional-component-rendering","title":"4. Conditional Component Rendering","text":"<p>Response wall CTA only renders if responses exist:</p> <pre><code>{campaign.responsesCount > 0 && (\n <Card>{/* Response wall CTA */}</Card>\n)}\n</code></pre> <p>Benefit: Cleaner DOM, faster initial render for new campaigns.</p>"},{"location":"v2/frontend/pages/public/campaign-page/#5-debounced-representative-filtering","title":"5. Debounced Representative Filtering","text":"<p>Filtering happens on blur/Enter, not on every keystroke:</p> <pre><code><Input\n onBlur={handlePostalCodeLookup}\n onPressEnter={handlePostalCodeLookup}\n // NOT: onChange={handlePostalCodeLookup}\n/>\n</code></pre> <p>Benefit: Prevents excessive API calls while user types.</p>"},{"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":"<p>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</p> <p>Steps Component: - Switches to vertical orientation - Step descriptions hidden on mobile (takes too much space) - Icons remain visible for visual guidance</p> <p>Representative Cards: - Single column layout on xs - Two columns on sm (tablet portrait) - Three columns on lg+ (desktop)</p> <p>Form Inputs: - Full-width inputs on mobile - size=\"large\" for better touch targets - Increased spacing between fields</p> <p>Email Preview: - TextArea expands to full width - Font size slightly smaller (13px) for better fit - Scrollable if content exceeds viewport</p>"},{"location":"v2/frontend/pages/public/campaign-page/#tablet-optimization","title":"Tablet Optimization","text":"<p>At <code>sm</code> 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</p>"},{"location":"v2/frontend/pages/public/campaign-page/#accessibility","title":"Accessibility","text":""},{"location":"v2/frontend/pages/public/campaign-page/#keyboard-navigation","title":"Keyboard Navigation","text":"<p>Step Navigation: - Steps component is keyboard accessible (Tab + Enter) - Arrow keys navigate between steps (native Ant Design) - Space bar activates step</p> <p>Form Fields: - All inputs focusable via Tab - Enter key submits postal code lookup - Escape key can close modals (future feature)</p> <p>Send Buttons: - Both \"Send Email\" and \"Open in Email App\" are focusable - Enter/Space activates button - Loading state prevents double-submission</p>"},{"location":"v2/frontend/pages/public/campaign-page/#aria-labels","title":"ARIA Labels","text":"<p>Step Indicator: <pre><code><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</code></pre></p> <p>Representative Photos: <pre><code><img\n src={rep.photo_url}\n alt={`Photo of ${rep.name}, ${rep.elected_office} for ${rep.district_name}`}\n role=\"img\"\n/>\n</code></pre></p> <p>Loading States: <pre><code><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</code></pre></p>"},{"location":"v2/frontend/pages/public/campaign-page/#screen-reader-support","title":"Screen Reader Support","text":"<p>Step Announcements: - Current step announced when changed - Step titles are clear and descriptive - Disabled steps have appropriate aria-disabled attribute</p> <p>Form Validation: <pre><code><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</code></pre></p> <p>Success/Error Messages: - Ant Design message component has ARIA live region - Screen reader announces \"Email sent successfully!\" - Error messages also announced automatically</p> <p>Email Preview: <pre><code><pre\n role=\"article\"\n aria-label=\"Email message preview\"\n>\n {emailPreview}\n</pre>\n</code></pre></p>"},{"location":"v2/frontend/pages/public/campaign-page/#color-contrast","title":"Color Contrast","text":"<p>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</p> <p>Primary Buttons: - Ant Design primary button (#1890ff) meets AA contrast - Focus outline visible on all interactive elements</p> <p>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</p>"},{"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":"<p>Symptoms: - Federal campaign shows provincial/municipal reps - All reps display regardless of campaign targets - Filtering logic not working</p> <p>Causes: 1. <code>government_level</code> field missing in API response 2. <code>governmentLevel</code> array empty in campaign 3. Case mismatch (Federal vs federal) 4. Filtering logic bug</p> <p>Solutions:</p> <pre><code>// 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</code></pre> <p>Check API response: <pre><code># 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</code></pre></p>"},{"location":"v2/frontend/pages/public/campaign-page/#issue-email-preview-not-updating","title":"Issue: Email Preview Not Updating","text":"<p>Symptoms: - Placeholders remain as <code>{name}</code> instead of actual values - User input not reflected in preview - Preview frozen on initial template</p> <p>Causes: 1. <code>useMemo</code> dependencies missing 2. State not updating properly 3. Placeholder regex not matching 4. Component not re-rendering</p> <p>Solutions:</p> <pre><code>// 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</code></pre>"},{"location":"v2/frontend/pages/public/campaign-page/#issue-send-button-not-working","title":"Issue: Send Button Not Working","text":"<p>Symptoms: - Clicking \"Send Email\" does nothing - No API request in Network tab - Button not disabled/loading</p> <p>Causes: 1. Missing form validation 2. Event handler not bound 3. API endpoint incorrect 4. CORS error blocking request</p> <p>Solutions:</p> <pre><code>// 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</code></pre> <p>Check CORS configuration: <pre><code>// In api/src/server.ts\napp.use(cors({\n origin: process.env.CORS_ORIGIN || 'http://localhost:3000',\n credentials: true\n}));\n</code></pre></p>"},{"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":"<p>Symptoms: - Representatives load but page stays on step 2 - User must manually click \"Next\" - Auto-advance logic not triggering</p> <p>Causes: 1. State update timing issue 2. Conditional check failing 3. React Strict Mode double-rendering 4. Missing <code>setCurrentStep(2)</code> call</p> <p>Solutions:</p> <pre><code>// 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</code></pre>"},{"location":"v2/frontend/pages/public/campaign-page/#issue-mailto-links-not-working","title":"Issue: Mailto Links Not Working","text":"<p>Symptoms: - Clicking \"Open in Email App\" does nothing - Browser blocks mailto: protocol - Email client doesn't open</p> <p>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)</p> <p>Solutions:</p> <pre><code>// 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</code></pre>"},{"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":"<ul> <li>Campaigns List Page - Campaign directory and featured campaigns</li> <li>Response Wall Page - Campaign-specific response display</li> <li>Map Page - Public location mapping</li> </ul>"},{"location":"v2/frontend/pages/public/campaign-page/#admin-pages","title":"Admin Pages","text":"<ul> <li>Campaigns Management - Campaign CRUD and settings</li> <li>Email Queue Page - Queue monitoring and management</li> <li>Response Moderation - Admin response management</li> </ul>"},{"location":"v2/frontend/pages/public/campaign-page/#components","title":"Components","text":"<ul> <li>PublicLayout - Dark theme layout wrapper</li> <li>ShareButtons - Social sharing functionality</li> </ul>"},{"location":"v2/frontend/pages/public/campaign-page/#api-documentation","title":"API Documentation","text":"<ul> <li>Public Campaigns API</li> <li>Campaign Email Sending</li> <li>Representatives Lookup</li> </ul>"},{"location":"v2/frontend/pages/public/campaign-page/#architecture","title":"Architecture","text":"<ul> <li>Email Queue System - BullMQ email processing</li> <li>Representative Caching</li> <li>Postal Code Lookup</li> </ul>"},{"location":"v2/frontend/pages/public/campaigns-list-page/","title":"Campaigns List Page","text":""},{"location":"v2/frontend/pages/public/campaigns-list-page/#overview","title":"Overview","text":"<p>File Path: <code>admin/src/pages/public/CampaignsListPage.tsx</code> (566 lines)</p> <p>Route: <code>/campaigns</code></p> <p>Role Requirements: Public access (no authentication required)</p> <p>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.</p> <p>Key Features:</p> <ul> <li>Hero banner with organization name and gradient background</li> <li>\"Find Your Representatives\" postal code lookup section</li> <li>Featured campaign card with gold border and star icon</li> <li>Responsive campaigns grid (3 columns on desktop)</li> <li>Individual campaign cards with cover photos or gradient backgrounds</li> <li>ShareButtons component for social media sharing</li> <li>Dark blue/teal theme consistent with public pages</li> <li>Real-time campaign statistics (emails sent, responses)</li> <li>Mobile-responsive design with hamburger navigation</li> </ul> <p>Layout: Uses <code>PublicLayout</code> component with dark theme (<code>colorBgBase: '#0d1b2a'</code>, <code>colorBgContainer: '#1b2838'</code>)</p>"},{"location":"v2/frontend/pages/public/campaigns-list-page/#features","title":"Features","text":""},{"location":"v2/frontend/pages/public/campaigns-list-page/#1-hero-banner","title":"1. Hero Banner","text":"<p>The hero section provides visual branding and context:</p> <ul> <li>Organization Name Display: Fetched from site settings API</li> <li>Gradient Background: <code>linear-gradient(135deg, #667eea 0%, #764ba2 100%)</code></li> <li>Typography: Large heading (32px desktop, 24px mobile)</li> <li>Tagline: \"Join thousands taking action\" with email icon</li> <li>Height: 250px on desktop, 200px on mobile</li> </ul>"},{"location":"v2/frontend/pages/public/campaigns-list-page/#2-find-your-representatives-section","title":"2. Find Your Representatives Section","text":"<p>Postal code lookup interface for representative discovery:</p> <ul> <li>Input Field: Text input with search icon prefix</li> <li>Loading States: Spinning icon during API lookup</li> <li>Representative Cards: Grid display (xs=1, sm=2, lg=3 columns)</li> <li>Card Details:</li> <li>Representative photo (150x150 circular avatar)</li> <li>Name with title formatting</li> <li>District/riding information</li> <li>Political party with badge styling</li> <li>Contact information (email, phone)</li> <li>Office address</li> <li>No Results State: Informative message with alternate contact suggestion</li> <li>Government Level Filtering: Shows reps from all applicable levels</li> </ul>"},{"location":"v2/frontend/pages/public/campaigns-list-page/#3-featured-campaign-card","title":"3. Featured Campaign Card","text":"<p>Highlighted campaign with premium styling:</p> <ul> <li>Gold Border: <code>2px solid #f39c12</code> with glow shadow</li> <li>Star Icon: Antd StarFilled in gold color</li> <li>\"Featured Campaign\" Badge: Gold text on dark background</li> <li>Cover Photo: Full-width image (300px height) with overlay gradient</li> <li>Fallback Gradient: Purple-to-blue gradient when no cover photo</li> <li>Statistics Display: Emails sent and responses count</li> <li>Action Button: Primary styled \"View Campaign\" link</li> <li>Positioning: Always appears first in grid</li> </ul>"},{"location":"v2/frontend/pages/public/campaigns-list-page/#4-campaigns-grid","title":"4. Campaigns Grid","text":"<p>Responsive grid layout for all campaigns:</p> <ul> <li>Responsive Columns:</li> <li>xs: 1 column (mobile)</li> <li>sm: 2 columns (tablet)</li> <li>lg: 3 columns (desktop)</li> <li>Gutter: 24px horizontal and vertical spacing</li> <li>Card Components: Ant Design Card with hover effects</li> <li>Card Contents:</li> <li>Cover photo or gradient background (200px height)</li> <li>Campaign title (Typography.Title level 4)</li> <li>Truncated description (2-line ellipsis)</li> <li>Government level tags (federal, provincial, municipal)</li> <li>Statistics row (emails sent, responses)</li> <li>\"View Campaign\" link button</li> </ul>"},{"location":"v2/frontend/pages/public/campaigns-list-page/#5-social-sharing","title":"5. Social Sharing","text":"<p>ShareButtons component integration:</p> <ul> <li>Platforms: X (Twitter), Facebook, LinkedIn, Reddit, Email, Copy Link</li> <li>URL Sharing: Current page URL</li> <li>Title Sharing: \"Check out these advocacy campaigns!\"</li> <li>Positioning: Below campaigns grid</li> <li>Icon Buttons: Circular buttons with platform-specific colors</li> <li>Copy Link Feedback: Success message notification</li> </ul>"},{"location":"v2/frontend/pages/public/campaigns-list-page/#6-empty-states","title":"6. Empty States","text":"<p>Graceful handling of no-data scenarios:</p> <ul> <li>No Campaigns: Large icon with \"No campaigns available\" message</li> <li>No Featured Campaign: Skips featured section, shows all campaigns equally</li> <li>Loading State: Ant Design Spin component with centered alignment</li> </ul>"},{"location":"v2/frontend/pages/public/campaigns-list-page/#user-workflow","title":"User Workflow","text":""},{"location":"v2/frontend/pages/public/campaigns-list-page/#initial-page-load","title":"Initial Page Load","text":"<ol> <li>User navigates to <code>/campaigns</code></li> <li>PublicLayout renders with dark theme</li> <li>Component fetches settings from <code>/api/settings</code></li> <li>Component fetches campaigns from <code>/api/public/campaigns</code></li> <li>Hero banner displays organization name</li> <li>Campaigns grid renders with featured campaign (if exists) highlighted</li> <li>ShareButtons component appears at bottom</li> </ol>"},{"location":"v2/frontend/pages/public/campaigns-list-page/#representative-lookup-flow","title":"Representative Lookup Flow","text":"<ol> <li>User enters postal code in \"Find Your Representatives\" input</li> <li>On blur or Enter key, component triggers lookup</li> <li>Loading spinner appears in input suffix</li> <li>API request to <code>/api/public/representatives/lookup?postalCode=X</code></li> <li>Results display in grid format with rep cards</li> <li>User can view contact details for each representative</li> <li>Empty state message if no results found</li> </ol>"},{"location":"v2/frontend/pages/public/campaigns-list-page/#campaign-browsing","title":"Campaign Browsing","text":"<ol> <li>User scrolls through campaigns grid</li> <li>Featured campaign (if exists) appears first with gold border</li> <li>User clicks \"View Campaign\" on any card</li> <li>Navigation to <code>/campaigns/:id</code> detail page</li> <li>Statistics update dynamically based on campaign activity</li> </ol>"},{"location":"v2/frontend/pages/public/campaigns-list-page/#social-sharing","title":"Social Sharing","text":"<ol> <li>User scrolls to bottom of page</li> <li>User clicks desired social platform icon</li> <li>Platform-specific share dialog opens (new window)</li> <li>For \"Copy Link\", URL copied to clipboard with notification</li> <li>User can share to multiple platforms sequentially</li> </ol>"},{"location":"v2/frontend/pages/public/campaigns-list-page/#component-structure","title":"Component Structure","text":"<pre><code>import React, { useState, useEffect } from 'react';\nimport { Link } from 'react-router-dom';\nimport { Row, Col, Card, Typography, Input, Spin, message, Tag, Grid } from 'antd';\nimport {\n MailOutlined,\n SearchOutlined,\n CommentOutlined,\n StarFilled,\n InboxOutlined\n} from '@ant-design/icons';\nimport PublicLayout from '../../components/PublicLayout';\nimport ShareButtons from '../../components/ShareButtons';\nimport axios from 'axios';\n\nconst { Title, Paragraph, Text } = Typography;\nconst { useBreakpoint } = Grid;\n\ninterface Campaign {\n id: string;\n title: string;\n description: string | null;\n slug: string;\n coverPhoto: string | null;\n governmentLevel: string[];\n targetType: string;\n isFeatured: boolean;\n isActive: boolean;\n emailsSentCount: number;\n responsesCount: number;\n}\n\ninterface Representative {\n name: string;\n district_name: string;\n elected_office: string;\n party_name: string;\n email: string;\n photo_url: string;\n offices: Array<{\n tel: string;\n type: string;\n postal: string;\n }>;\n}\n\ninterface Settings {\n organizationName: string;\n}\n\nconst CampaignsListPage: React.FC = () => {\n const [campaigns, setCampaigns] = useState<Campaign[]>([]);\n const [settings, setSettings] = useState<Settings | null>(null);\n const [loading, setLoading] = useState(true);\n const [postalCode, setPostalCode] = useState('');\n const [representatives, setRepresentatives] = useState<Representative[]>([]);\n const [repsLoading, setRepsLoading] = useState(false);\n const screens = useBreakpoint();\n const isMobile = !screens.md;\n\n // Data fetching, event handlers, etc.\n\n return (\n <PublicLayout>\n {/* Hero Banner */}\n <div className=\"hero-banner\">\n {/* Content */}\n </div>\n\n {/* Find Your Representatives */}\n <div className=\"find-reps-section\">\n {/* Postal code input and results */}\n </div>\n\n {/* Campaigns Grid */}\n <div className=\"campaigns-grid\">\n <Row gutter={[24, 24]}>\n {/* Featured campaign */}\n {/* Regular campaigns */}\n </Row>\n </div>\n\n {/* Social Sharing */}\n <ShareButtons\n url={window.location.href}\n title=\"Check out these advocacy campaigns!\"\n />\n </PublicLayout>\n );\n};\n\nexport default CampaignsListPage;\n</code></pre>"},{"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":"<pre><code>// 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</code></pre>"},{"location":"v2/frontend/pages/public/campaigns-list-page/#derived-state","title":"Derived State","text":"<pre><code>// 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</code></pre>"},{"location":"v2/frontend/pages/public/campaigns-list-page/#state-flow","title":"State Flow","text":"<ol> <li>Initial Load: <code>loading=true</code>, fetch campaigns and settings in parallel</li> <li>Data Received: <code>setCampaigns()</code>, <code>setSettings()</code>, <code>setLoading(false)</code></li> <li>Postal Code Entry: User types, <code>setPostalCode()</code> updates state</li> <li>Lookup Trigger: On blur/Enter, <code>setRepsLoading(true)</code>, fetch reps</li> <li>Reps Received: <code>setRepresentatives()</code>, <code>setRepsLoading(false)</code></li> <li>Error Handling: Display message.error(), reset loading states</li> </ol>"},{"location":"v2/frontend/pages/public/campaigns-list-page/#api-integration","title":"API Integration","text":""},{"location":"v2/frontend/pages/public/campaigns-list-page/#endpoints-used","title":"Endpoints Used","text":""},{"location":"v2/frontend/pages/public/campaigns-list-page/#1-get-settings","title":"1. Get Settings","text":"<pre><code>GET /api/settings\n</code></pre> <p>Response: <pre><code>{\n \"organizationName\": \"Progressive Action Network\",\n \"contactEmail\": \"contact@example.org\",\n \"allowPublicRegistration\": true,\n \"defaultMapCenter\": [45.5017, -73.5673],\n \"defaultMapZoom\": 12\n}\n</code></pre></p>"},{"location":"v2/frontend/pages/public/campaigns-list-page/#2-list-public-campaigns","title":"2. List Public Campaigns","text":"<pre><code>GET /api/public/campaigns\n</code></pre> <p>Response: <pre><code>[\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</code></pre></p>"},{"location":"v2/frontend/pages/public/campaigns-list-page/#3-lookup-representatives","title":"3. Lookup Representatives","text":"<pre><code>GET /api/public/representatives/lookup?postalCode=K1A0B1\n</code></pre> <p>Response: <pre><code>[\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</code></pre></p>"},{"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":"<pre><code>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</code></pre>"},{"location":"v2/frontend/pages/public/campaigns-list-page/#lookup-representatives","title":"Lookup Representatives","text":"<pre><code>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</code></pre>"},{"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":"<pre><code><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</code></pre>"},{"location":"v2/frontend/pages/public/campaigns-list-page/#representative-lookup-section","title":"Representative Lookup Section","text":"<pre><code><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</code></pre>"},{"location":"v2/frontend/pages/public/campaigns-list-page/#featured-campaign-card","title":"Featured Campaign Card","text":"<pre><code>{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</code></pre>"},{"location":"v2/frontend/pages/public/campaigns-list-page/#regular-campaign-cards","title":"Regular Campaign Cards","text":"<pre><code>{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</code></pre>"},{"location":"v2/frontend/pages/public/campaigns-list-page/#empty-state","title":"Empty State","text":"<pre><code>{!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</code></pre>"},{"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":"<p>Campaigns and settings fetched simultaneously using <code>Promise.all()</code>:</p> <pre><code>const [campaignsRes, settingsRes] = await Promise.all([\n axios.get('/api/public/campaigns'),\n axios.get('/api/settings')\n]);\n</code></pre> <p>Benefit: Reduces initial page load time by ~50% vs sequential requests.</p>"},{"location":"v2/frontend/pages/public/campaigns-list-page/#2-image-loading-optimization","title":"2. Image Loading Optimization","text":"<ul> <li>Object-fit: <code>objectFit: 'cover'</code> prevents layout shift</li> <li>Fixed Heights: Cover photos have defined heights (300px featured, 200px regular)</li> <li>Fallback Gradients: Instant render when no cover photo exists</li> <li>Lazy Loading: Browser-native lazy loading for off-screen images (future enhancement)</li> </ul>"},{"location":"v2/frontend/pages/public/campaigns-list-page/#3-conditional-rendering","title":"3. Conditional Rendering","text":"<p>Representative lookup section only renders when results exist:</p> <pre><code>{representatives.length > 0 && (\n <Row gutter={[16, 16]}>\n {/* Rep cards */}\n </Row>\n)}\n</code></pre> <p>Benefit: Avoids unnecessary DOM nodes and improves TTI (Time to Interactive).</p>"},{"location":"v2/frontend/pages/public/campaigns-list-page/#4-responsive-grid-optimization","title":"4. Responsive Grid Optimization","text":"<p>Ant Design Grid uses CSS Grid under the hood:</p> <pre><code><Row gutter={[24, 24]}>\n <Col xs={24} sm={12} lg={8}>\n</code></pre> <p>Benefit: No JavaScript-based layout calculations, pure CSS performance.</p>"},{"location":"v2/frontend/pages/public/campaigns-list-page/#5-memoization-opportunities-future-enhancement","title":"5. Memoization Opportunities (Future Enhancement)","text":"<p>Featured/regular campaign split could use <code>useMemo</code>:</p> <pre><code>const { featuredCampaign, regularCampaigns } = useMemo(() => ({\n featuredCampaign: campaigns.find(c => c.isFeatured),\n regularCampaigns: campaigns.filter(c => !c.isFeatured)\n}), [campaigns]);\n</code></pre>"},{"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":"<pre><code>const screens = useBreakpoint();\nconst isMobile = !screens.md; // md breakpoint = 768px\n</code></pre> 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":"<p>Hero Banner: - Reduced padding (60px vs 80px vertical) - Smaller title font (24px vs 32px) - Maintained gradient for visual impact</p> <p>Representative Cards: - Stack to single column on mobile - Maintain circular avatar size (150px) - Full-width buttons for better touch targets</p> <p>Campaign Cards: - Single column layout on mobile - Cover photo height remains 200px (cropped if needed) - Action buttons become full-width</p> <p>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</p>"},{"location":"v2/frontend/pages/public/campaigns-list-page/#tablet-optimization","title":"Tablet Optimization","text":"<p>At <code>sm</code> 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</p>"},{"location":"v2/frontend/pages/public/campaigns-list-page/#accessibility","title":"Accessibility","text":""},{"location":"v2/frontend/pages/public/campaigns-list-page/#keyboard-navigation","title":"Keyboard Navigation","text":"<p>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</p> <p>Focus Management: <pre><code><Input\n onPressEnter={handlePostalCodeLookup}\n // Focus indicator via Ant Design theme\n/>\n</code></pre></p>"},{"location":"v2/frontend/pages/public/campaigns-list-page/#aria-labels","title":"ARIA Labels","text":"<p>Representative Photos: <pre><code><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</code></pre></p> <p>Loading States: <pre><code><Spin size=\"small\" aria-label=\"Loading representatives\" />\n</code></pre></p> <p>Icon Buttons: <pre><code><Button\n icon={<SearchOutlined />}\n aria-label=\"Search for representatives\"\n>\n Find Representatives\n</Button>\n</code></pre></p>"},{"location":"v2/frontend/pages/public/campaigns-list-page/#screen-reader-support","title":"Screen Reader Support","text":"<p>Structural Headings: - Page uses semantic heading hierarchy (h1 \u2192 h2 \u2192 h3 \u2192 h4) - Hero uses <code><Title level={1}></code> for main page title - Sections use <code><Title level={2}></code> for logical grouping</p> <p>Empty States: - Informative messages for \"No campaigns\" and \"No representatives found\" - Visual icons paired with text labels</p> <p>Statistics: <pre><code><Text strong>{campaign.emailsSentCount}</Text>\n<br />\n<Text type=\"secondary\">Emails Sent</Text>\n// Screen reader announces: \"1247 Emails Sent\"\n</code></pre></p>"},{"location":"v2/frontend/pages/public/campaigns-list-page/#color-contrast","title":"Color Contrast","text":"<p>Dark Theme Compliance: - Background <code>#0d1b2a</code> with white text meets WCAG AA (7.8:1 ratio) - Links use <code>#1890ff</code> with sufficient contrast (4.6:1 ratio) - Tag colors (blue, purple, gold) all meet AA standards</p> <p>Interactive States: - Hover effects use opacity changes (accessible to screen readers) - Focus states use browser default outline (visible on all elements)</p>"},{"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":"<p>Symptoms: - Postal code input shows no results - Console shows 404 or 500 error - Loading spinner stuck</p> <p>Causes: 1. Invalid postal code format (must be Canadian: <code>A1A 1A1</code>) 2. Represent API rate limiting (429 response) 3. Redis cache connection failure 4. Network timeout</p> <p>Solutions:</p> <pre><code>// 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</code></pre>"},{"location":"v2/frontend/pages/public/campaigns-list-page/#issue-cover-photos-not-displaying","title":"Issue: Cover Photos Not Displaying","text":"<p>Symptoms: - Campaign cards show gradient instead of uploaded photos - Console shows CORS errors - Broken image icons</p> <p>Causes: 1. Invalid image URL in database 2. CORS policy blocking external images 3. Image file deleted from storage 4. Incorrect Nginx configuration</p> <p>Solutions:</p> <pre><code>// 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</code></pre> <p>Check Nginx configuration: <pre><code># 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</code></pre></p>"},{"location":"v2/frontend/pages/public/campaigns-list-page/#issue-featured-campaign-not-appearing-first","title":"Issue: Featured Campaign Not Appearing First","text":"<p>Symptoms: - Featured campaign appears in middle/end of grid - Gold border not visible - Star icon missing</p> <p>Causes: 1. <code>isFeatured</code> flag not set in database 2. Multiple campaigns marked as featured 3. Grid rendering logic error</p> <p>Solutions:</p> <pre><code>// 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</code></pre> <p>Check database: <pre><code>-- 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</code></pre></p>"},{"location":"v2/frontend/pages/public/campaigns-list-page/#issue-sharebuttons-not-working","title":"Issue: ShareButtons Not Working","text":"<p>Symptoms: - Clicking share icons does nothing - \"Copy Link\" doesn't copy to clipboard - No new windows opening</p> <p>Causes: 1. Popup blockers preventing window.open() 2. Clipboard API not available (non-HTTPS) 3. ShareButtons component not imported 4. Missing event handlers</p> <p>Solutions:</p> <pre><code>// 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</code></pre>"},{"location":"v2/frontend/pages/public/campaigns-list-page/#issue-page-loading-very-slowly","title":"Issue: Page Loading Very Slowly","text":"<p>Symptoms: - Spinner shows for 5+ seconds - Network tab shows slow API responses - Images take long to load</p> <p>Causes: 1. Large campaign list (100+ campaigns) 2. High-resolution cover photos (5MB+ files) 3. No database indexes on <code>isActive</code> column 4. N+1 query problem (not in this case, single query)</p> <p>Solutions:</p> <p>Add pagination (API change required): <pre><code>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</code></pre></p> <p>Optimize images server-side: <pre><code># Add image resizing in upload pipeline\n# Max width: 1200px, quality: 80%\nconvert input.jpg -resize 1200x -quality 80 output.jpg\n</code></pre></p> <p>Add database index: <pre><code>CREATE INDEX idx_campaign_active_featured\nON \"Campaign\" (\"isActive\", \"isFeatured\", \"updatedAt\" DESC);\n</code></pre></p>"},{"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":"<ul> <li>Campaign Detail Page - Individual campaign view with email sending</li> <li>Response Wall Page - Public response submission and display</li> <li>Map Page - Public location map</li> </ul>"},{"location":"v2/frontend/pages/public/campaigns-list-page/#admin-pages","title":"Admin Pages","text":"<ul> <li>Campaigns Management - Campaign CRUD and configuration</li> <li>Representatives Admin - Rep cache management</li> <li>Settings Page - Organization name configuration</li> </ul>"},{"location":"v2/frontend/pages/public/campaigns-list-page/#components","title":"Components","text":"<ul> <li>PublicLayout - Dark theme layout wrapper</li> <li>ShareButtons - Social sharing component</li> </ul>"},{"location":"v2/frontend/pages/public/campaigns-list-page/#api-documentation","title":"API Documentation","text":"<ul> <li>Public Campaigns API</li> <li>Representatives API</li> <li>Settings API</li> </ul>"},{"location":"v2/frontend/pages/public/campaigns-list-page/#architecture","title":"Architecture","text":"<ul> <li>V2 Architecture Overview</li> <li>Public vs Admin Routing</li> <li>Ant Design Theme Configuration</li> </ul>"},{"location":"v2/frontend/pages/public/landing-page/","title":"Landing Page (Public Page Renderer)","text":""},{"location":"v2/frontend/pages/public/landing-page/#overview","title":"Overview","text":"<p>File Path: <code>admin/src/pages/public/LandingPage.tsx</code> (68 lines)</p> <p>Route: <code>/p/:slug</code></p> <p>Role Requirements: Public access</p> <p>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.</p> <p>Key Features:</p> <ul> <li>Minimal wrapper around admin-authored HTML</li> <li>SEO meta tags (title, description, og:image)</li> <li>dangerouslySetInnerHTML for HTML + CSS rendering</li> <li>Loading spinner during fetch</li> <li>404 page for invalid slugs</li> <li>No layout wrapper (pages are self-contained)</li> </ul>"},{"location":"v2/frontend/pages/public/landing-page/#features","title":"Features","text":""},{"location":"v2/frontend/pages/public/landing-page/#1-seo-meta-tags","title":"1. SEO Meta Tags","text":"<pre><code><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</code></pre>"},{"location":"v2/frontend/pages/public/landing-page/#2-html-rendering","title":"2. HTML Rendering","text":"<pre><code><div dangerouslySetInnerHTML={{ __html: page.html }} />\n<style>{page.css}</style>\n</code></pre>"},{"location":"v2/frontend/pages/public/landing-page/#3-loading-state","title":"3. Loading State","text":"<pre><code>{loading && (\n <div style={{ textAlign: 'center', padding: 100 }}>\n <Spin size=\"large\" />\n </div>\n)}\n</code></pre>"},{"location":"v2/frontend/pages/public/landing-page/#4-404-handling","title":"4. 404 Handling","text":"<pre><code>{!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</code></pre>"},{"location":"v2/frontend/pages/public/landing-page/#api-integration","title":"API Integration","text":"<pre><code>GET /api/public/pages/:slug\n</code></pre> <p>Response: <pre><code>{\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</code></pre></p>"},{"location":"v2/frontend/pages/public/landing-page/#security-considerations","title":"Security Considerations","text":"<p>XSS Risk Accepted: - Pages authored by trusted admins only - dangerouslySetInnerHTML allows full HTML/JS - No user-submitted content - Alternative would break GrapesJS output</p>"},{"location":"v2/frontend/pages/public/landing-page/#related-documentation","title":"Related Documentation","text":"<ul> <li>Landing Pages Admin</li> <li>Page Editor</li> <li>GrapesJS Component</li> </ul>"},{"location":"v2/frontend/pages/public/map-page/","title":"Public Map Page","text":""},{"location":"v2/frontend/pages/public/map-page/#overview","title":"Overview","text":"<p>File Path: <code>admin/src/pages/public/MapPage.tsx</code> (474 lines)</p> <p>Route: <code>/map</code></p> <p>Role Requirements: Public access (no authentication required)</p> <p>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.</p> <p>Key Features:</p> <ul> <li>Full-viewport Leaflet map with minimal header (48px)</li> <li>OpenStreetMap tile layer</li> <li>Color-coded circle markers by support level (Strong/Leaning/Undecided/Opposed/No Answer)</li> <li>Multi-unit building popups with sorted unit lists</li> <li>Cut polygon overlays with toggle controls</li> <li>Geolocate button (find my location)</li> <li>Fullscreen button</li> <li>Viewport-based location loading with 800ms debounce</li> <li>GPS position marker when geolocation active</li> <li>Dark theme header consistent with public pages</li> </ul> <p>Layout: Uses <code>PublicLayout</code> with custom header override (thin, 48px)</p>"},{"location":"v2/frontend/pages/public/map-page/#features","title":"Features","text":""},{"location":"v2/frontend/pages/public/map-page/#1-thin-header-design","title":"1. Thin Header Design","text":"<p>Minimal header to maximize map space:</p> <ul> <li>Height: 48px (vs standard 64px)</li> <li>Background: Dark blue (<code>#0d1b2a</code>)</li> <li>Logo: Organization name with map icon</li> <li>No Navigation Menu: Map is primary content</li> <li>Mobile Responsive: Hamburger menu available</li> </ul>"},{"location":"v2/frontend/pages/public/map-page/#2-color-coded-location-markers","title":"2. Color-Coded Location Markers","text":"<p>Visual support level indication:</p> <ul> <li>Strong Support: Green (<code>#52c41a</code>)</li> <li>Leaning Support: Light green (<code>#95de64</code>)</li> <li>Undecided: Yellow (<code>#fadb14</code>)</li> <li>Leaning Opposed: Orange (<code>#ff7a45</code>)</li> <li>Opposed: Red (<code>#f5222d</code>)</li> <li>No Answer: Gray (<code>#8c8c8c</code>)</li> <li>Not Home: Light gray (<code>#d9d9d9</code>)</li> </ul> <p>Marker Styling: - Circle radius: 8px - Stroke: White 2px - Fill opacity: 0.8 - Hover: Increased opacity (1.0)</p>"},{"location":"v2/frontend/pages/public/map-page/#3-multi-unit-building-popups","title":"3. Multi-Unit Building Popups","text":"<p>Aggregated building display:</p> <p>Popup Header: - Purple background (<code>#722ed1</code>) - Building address - Total unit count badge</p> <p>Unit List: - Sorted by unit number (alphanumeric) - Each row: Unit | Support Level | Notes - Color-coded support badges - Scrollable if >10 units - Max height: 300px</p> <p>Example: <pre><code>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</code></pre></p>"},{"location":"v2/frontend/pages/public/map-page/#4-cut-polygon-overlays","title":"4. Cut Polygon Overlays","text":"<p>Geographic boundary visualization:</p> <p>Polygon Rendering: - GeoJSON format from database - Blue stroke (<code>#1890ff</code>) - Semi-transparent fill (opacity: 0.2) - Label at centroid (cut name)</p> <p>Toggle Controls: - Floating panel (bottom-left, above zoom) - Checkbox per cut - Select All / Deselect All buttons - Collapse/expand panel</p> <p>Cut Label Styling: - White text with black outline - Always visible (not obscured by fill) - Click cut to toggle visibility</p>"},{"location":"v2/frontend/pages/public/map-page/#5-viewport-based-loading","title":"5. Viewport-Based Loading","text":"<p>Performance optimization for large datasets:</p> <p>Loading Strategy: - Fetch only locations in current map bounds - Trigger on <code>moveend</code> event (pan/zoom complete) - Debounce 800ms to prevent excessive requests - Loading spinner in top-right during fetch</p> <p>Bounds Calculation: <pre><code>const bounds = map.getBounds();\nconst params = {\n minLat: bounds.getSouth(),\n maxLat: bounds.getNorth(),\n minLng: bounds.getWest(),\n maxLng: bounds.getEast()\n};\n</code></pre></p>"},{"location":"v2/frontend/pages/public/map-page/#6-geolocation","title":"6. Geolocation","text":"<p>User position tracking:</p> <p>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</p> <p>Geolocate Button: - Floating control (top-right) - Compass icon - Primary color when active - Error message if unavailable</p>"},{"location":"v2/frontend/pages/public/map-page/#7-fullscreen-mode","title":"7. Fullscreen Mode","text":"<p>Immersive map experience:</p> <p>Activation: - Fullscreen button (top-right, below geolocate) - Browser Fullscreen API - Fallback for Safari (<code>webkitRequestFullscreen</code>)</p> <p>Exit: - ESC key - Exit fullscreen button (shows when active) - Browser native controls</p>"},{"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":"<ol> <li>User navigates to <code>/map</code></li> <li>PublicLayout renders with thin header</li> <li>Map initializes at default center/zoom (from settings)</li> <li>Viewport bounds calculated</li> <li>API fetches locations within bounds</li> <li>Circle markers render for each location</li> <li>Cuts fetched and rendered (all visible by default)</li> </ol>"},{"location":"v2/frontend/pages/public/map-page/#exploring-locations","title":"Exploring Locations","text":"<ol> <li>User pans map to new area</li> <li><code>moveend</code> event triggers after 800ms debounce</li> <li>New viewport bounds calculated</li> <li>API fetches locations in new bounds</li> <li>Existing markers cleared</li> <li>New markers rendered</li> <li>User clicks marker to view popup</li> <li>Popup shows address, support level, notes, last visit date</li> </ol>"},{"location":"v2/frontend/pages/public/map-page/#viewing-multi-unit-buildings","title":"Viewing Multi-Unit Buildings","text":"<ol> <li>User clicks purple building marker</li> <li>Popup opens with building header</li> <li>Unit list displays sorted units</li> <li>User scrolls list (if >10 units)</li> <li>User sees color-coded support levels per unit</li> <li>User closes popup by clicking outside or X button</li> </ol>"},{"location":"v2/frontend/pages/public/map-page/#using-geolocation","title":"Using Geolocation","text":"<ol> <li>User clicks geolocate button</li> <li>Browser prompts for location permission</li> <li>User grants permission</li> <li>Blue pulsing marker appears at user's position</li> <li>Map pans to center on user</li> <li>Accuracy circle shows GPS precision</li> <li>User can pan away (marker remains visible)</li> </ol>"},{"location":"v2/frontend/pages/public/map-page/#toggling-cut-visibility","title":"Toggling Cut Visibility","text":"<ol> <li>User clicks \"Cut Controls\" button (bottom-left)</li> <li>Panel expands showing cut checkboxes</li> <li>User unchecks \"Cut A\"</li> <li>\"Cut A\" polygon disappears from map</li> <li>User clicks \"Deselect All\"</li> <li>All polygons hidden</li> <li>User clicks \"Select All\"</li> <li>All polygons re-appear</li> </ol>"},{"location":"v2/frontend/pages/public/map-page/#fullscreen-mode","title":"Fullscreen Mode","text":"<ol> <li>User clicks fullscreen button</li> <li>Map expands to fill entire screen</li> <li>Header hidden</li> <li>Controls remain visible</li> <li>User explores map at full size</li> <li>User presses ESC key</li> <li>Map returns to normal layout</li> </ol>"},{"location":"v2/frontend/pages/public/map-page/#component-structure","title":"Component Structure","text":"<pre><code>import React, { useState, useEffect, useCallback } from 'react';\nimport { MapContainer, TileLayer, CircleMarker, Popup, useMap, useMapEvents, Polygon } from 'react-leaflet';\nimport { Button, Spin, Checkbox, Space, Typography, Badge } from 'antd';\nimport {\n AimOutlined,\n FullscreenOutlined,\n FullscreenExitOutlined,\n EnvironmentOutlined\n} from '@ant-design/icons';\nimport { debounce } from 'lodash';\nimport PublicLayout from '../../components/PublicLayout';\nimport axios from 'axios';\nimport 'leaflet/dist/leaflet.css';\n\nconst { Text } = Typography;\n\ninterface Location {\n id: string;\n address: string;\n latitude: number;\n longitude: number;\n supportLevel: string | null;\n notes: string | null;\n lastVisitDate: string | null;\n isMultiUnit: boolean;\n units?: Array<{\n unitNumber: string;\n supportLevel: string | null;\n notes: string | null;\n }>;\n}\n\ninterface Cut {\n id: string;\n name: string;\n color: string;\n polygon: any; // GeoJSON\n}\n\nconst MapPage: React.FC = () => {\n const [locations, setLocations] = useState<Location[]>([]);\n const [cuts, setCuts] = useState<Cut[]>([]);\n const [visibleCuts, setVisibleCuts] = useState<Set<string>>(new Set());\n const [loading, setLoading] = useState(false);\n const [userPosition, setUserPosition] = useState<[number, number] | null>(null);\n const [mapCenter, setMapCenter] = useState<[number, number]>([45.5017, -73.5673]);\n const [mapZoom, setMapZoom] = useState(13);\n\n // Component logic...\n\n return (\n <PublicLayout headerHeight={48}>\n <MapContainer\n center={mapCenter}\n zoom={mapZoom}\n style={{ height: 'calc(100vh - 48px)', width: '100%' }}\n zoomControl={false}\n >\n <TileLayer\n url=\"https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png\"\n attribution='&copy; <a href=\"https://www.openstreetmap.org/copyright\">OpenStreetMap</a>'\n />\n\n {/* Locations */}\n {/* Cuts */}\n {/* User Position */}\n {/* Controls */}\n </MapContainer>\n </PublicLayout>\n );\n};\n</code></pre>"},{"location":"v2/frontend/pages/public/map-page/#state-management","title":"State Management","text":"<pre><code>// 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</code></pre>"},{"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":"<pre><code>GET /api/public/map/locations?minLat=45.4&maxLat=45.6&minLng=-73.7&maxLng=-73.4\n</code></pre> <p>Response: <pre><code>[\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</code></pre></p>"},{"location":"v2/frontend/pages/public/map-page/#2-get-cuts","title":"2. Get Cuts","text":"<pre><code>GET /api/public/map/cuts\n</code></pre> <p>Response: <pre><code>[\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</code></pre></p>"},{"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":"<pre><code>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</code></pre>"},{"location":"v2/frontend/pages/public/map-page/#color-coded-location-markers","title":"Color-Coded Location Markers","text":"<pre><code>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</code></pre>"},{"location":"v2/frontend/pages/public/map-page/#multi-unit-building-popup","title":"Multi-Unit Building Popup","text":"<pre><code>{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</code></pre>"},{"location":"v2/frontend/pages/public/map-page/#performance-considerations","title":"Performance Considerations","text":"<ol> <li>Debounced Loading: 800ms debounce prevents excessive API calls during panning</li> <li>Viewport Filtering: Only loads visible locations (scalable to 10,000+ locations)</li> <li>React-Leaflet Optimization: Uses <code>key</code> prop to prevent unnecessary re-renders</li> <li>Lazy Popup Rendering: Popups created on-demand, not upfront</li> </ol>"},{"location":"v2/frontend/pages/public/map-page/#responsive-design","title":"Responsive Design","text":"<ul> <li>Mobile: Full viewport height minus 48px header</li> <li>Touch Gestures: Native Leaflet touch support (pinch zoom, swipe pan)</li> <li>Fullscreen: Available on all devices via browser API</li> </ul>"},{"location":"v2/frontend/pages/public/map-page/#accessibility","title":"Accessibility","text":"<ul> <li>Keyboard Navigation: Map focusable, arrow keys pan</li> <li>Button Labels: All control buttons have aria-labels</li> <li>Color Contrast: Marker strokes ensure visibility on all backgrounds</li> <li>Screen Reader: Popup content readable, location count announced</li> </ul>"},{"location":"v2/frontend/pages/public/map-page/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/frontend/pages/public/map-page/#issue-markers-not-appearing","title":"Issue: Markers Not Appearing","text":"<p>Causes: 1. Locations outside viewport bounds 2. API returning empty array 3. Leaflet CSS not imported</p> <p>Solutions: <pre><code>import 'leaflet/dist/leaflet.css'; // Must be imported\n\n// Add debug logging\nuseEffect(() => {\n console.log(`Loaded ${locations.length} locations`);\n}, [locations]);\n</code></pre></p>"},{"location":"v2/frontend/pages/public/map-page/#issue-geolocation-not-working","title":"Issue: Geolocation Not Working","text":"<p>Causes: 1. HTTPS required for geolocation API 2. User denied permission 3. Browser doesn't support geolocation</p> <p>Solutions: <pre><code>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</code></pre></p>"},{"location":"v2/frontend/pages/public/map-page/#related-documentation","title":"Related Documentation","text":"<ul> <li>Admin Map View</li> <li>Locations Page</li> <li>Cuts Page</li> <li>Map Settings</li> </ul>"},{"location":"v2/frontend/pages/public/media-gallery-page/","title":"Media Gallery Page","text":""},{"location":"v2/frontend/pages/public/media-gallery-page/#overview","title":"Overview","text":"<p>File Path: <code>admin/src/pages/public/MediaGalleryPage.tsx</code> (195 lines)</p> <p>Route: <code>/media</code> (with optional <code>?category=X</code> query param)</p> <p>Role Requirements: Public access</p> <p>Purpose: Public-facing video gallery displaying shared media content with search, sort, category filtering, and pagination.</p> <p>Key Features:</p> <ul> <li>Search input with 300ms debounce</li> <li>Sort dropdown (Recent, Popular, Most Viewed)</li> <li>Responsive grid (xs=1, sm=2, md=3, lg=4 columns)</li> <li>PublicVideoCard component</li> <li>Pagination (24 videos per page)</li> <li>Category filter from URL params</li> <li>Dark theme consistency</li> </ul> <p>Layout: Uses <code>MediaPublicLayout</code> (specialized public layout for media)</p>"},{"location":"v2/frontend/pages/public/media-gallery-page/#features","title":"Features","text":""},{"location":"v2/frontend/pages/public/media-gallery-page/#1-search-bar","title":"1. Search Bar","text":"<pre><code><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</code></pre>"},{"location":"v2/frontend/pages/public/media-gallery-page/#2-sort-dropdown","title":"2. Sort Dropdown","text":"<p>Options: - Recent: <code>createdAt DESC</code> - Popular: <code>reactionCount DESC</code> - Most Viewed: <code>viewCount DESC</code></p>"},{"location":"v2/frontend/pages/public/media-gallery-page/#3-video-grid","title":"3. Video Grid","text":"<pre><code><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</code></pre>"},{"location":"v2/frontend/pages/public/media-gallery-page/#4-category-filter","title":"4. Category Filter","text":"<p>URL-based filtering: - <code>/media</code> - All categories - <code>/media?category=testimonials</code> - Testimonials only - <code>/media?category=events</code> - Events only</p>"},{"location":"v2/frontend/pages/public/media-gallery-page/#api-integration","title":"API Integration","text":"<pre><code>GET /api/media/public?page=1&limit=24&search=climate&sort=recent&category=testimonials\n</code></pre> <p>Response: <pre><code>{\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</code></pre></p>"},{"location":"v2/frontend/pages/public/media-gallery-page/#related-documentation","title":"Related Documentation","text":"<ul> <li>Media Viewer Page</li> <li>Library Management</li> <li>Shared Media Admin</li> </ul>"},{"location":"v2/frontend/pages/public/media-viewer-page/","title":"Media Viewer Page","text":""},{"location":"v2/frontend/pages/public/media-viewer-page/#overview","title":"Overview","text":"<p>File Path: <code>admin/src/pages/public/MediaViewerPage.tsx</code> (306 lines)</p> <p>Route: <code>/media/:id</code></p> <p>Role Requirements: Public access (locked videos require login)</p> <p>Purpose: Individual video player page with metadata, reactions, comments, and related videos.</p> <p>Key Features:</p> <ul> <li>Back button to gallery</li> <li>VideoPlayer component with time tracking</li> <li>Metadata display (views, upvotes, category, quality tags)</li> <li>Upvote button (toggleable, session-based)</li> <li>ReactionButtons component (6 emojis)</li> <li>CommentSection component</li> <li>Related videos grid (3 cards)</li> <li>Locked video modal (redirect to login)</li> </ul>"},{"location":"v2/frontend/pages/public/media-viewer-page/#features","title":"Features","text":""},{"location":"v2/frontend/pages/public/media-viewer-page/#1-video-player","title":"1. Video Player","text":"<pre><code><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</code></pre>"},{"location":"v2/frontend/pages/public/media-viewer-page/#2-metadata-display","title":"2. Metadata Display","text":"<pre><code><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</code></pre>"},{"location":"v2/frontend/pages/public/media-viewer-page/#3-upvote-button","title":"3. Upvote Button","text":"<pre><code><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</code></pre>"},{"location":"v2/frontend/pages/public/media-viewer-page/#4-reaction-buttons","title":"4. Reaction Buttons","text":"<p>6 emoji reactions: - \ud83d\udc4d Like - \u2764\ufe0f Love - \ud83d\ude02 Haha - \ud83d\ude2e Wow - \ud83d\ude22 Sad - \ud83d\ude21 Angry</p> <pre><code><ReactionButtons\n videoId={video.id}\n reactions={video.reactions}\n onReact={handleReact}\n/>\n</code></pre>"},{"location":"v2/frontend/pages/public/media-viewer-page/#5-comment-section","title":"5. Comment Section","text":"<pre><code><CommentSection\n videoId={video.id}\n comments={comments}\n onSubmit={handleCommentSubmit}\n/>\n</code></pre>"},{"location":"v2/frontend/pages/public/media-viewer-page/#6-related-videos","title":"6. Related Videos","text":"<pre><code><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</code></pre>"},{"location":"v2/frontend/pages/public/media-viewer-page/#7-locked-video-handling","title":"7. Locked Video Handling","text":"<pre><code>{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</code></pre>"},{"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":"<pre><code>GET /api/media/public/:id\n</code></pre>"},{"location":"v2/frontend/pages/public/media-viewer-page/#2-track-view","title":"2. Track View","text":"<pre><code>POST /api/media/public/:id/view\nContent-Type: application/json\n\n{\n \"currentTime\": 67.5\n}\n</code></pre>"},{"location":"v2/frontend/pages/public/media-viewer-page/#3-toggle-upvote","title":"3. Toggle Upvote","text":"<pre><code>POST /api/media/public/:id/upvote\n</code></pre>"},{"location":"v2/frontend/pages/public/media-viewer-page/#4-add-reaction","title":"4. Add Reaction","text":"<pre><code>POST /api/media/public/:id/react\nContent-Type: application/json\n\n{\n \"reactionType\": \"love\"\n}\n</code></pre>"},{"location":"v2/frontend/pages/public/media-viewer-page/#performance-considerations","title":"Performance Considerations","text":"<ol> <li>View Tracking: Throttled to 30-second intervals</li> <li>Related Videos: Limited to 3 (prevents over-fetching)</li> <li>Lazy Comments: Loaded separately after video metadata</li> <li>Video Preload: <code>preload=\"metadata\"</code> for faster initial render</li> </ol>"},{"location":"v2/frontend/pages/public/media-viewer-page/#accessibility","title":"Accessibility","text":"<ul> <li>Keyboard Controls: Native video player controls</li> <li>Captions: Support for WebVTT subtitle files</li> <li>Screen Reader: All buttons have aria-labels</li> <li>Focus Management: Reaction buttons keyboard navigable</li> </ul>"},{"location":"v2/frontend/pages/public/media-viewer-page/#related-documentation","title":"Related Documentation","text":"<ul> <li>Media Gallery Page</li> <li>Video Upload</li> <li>Media API</li> </ul>"},{"location":"v2/frontend/pages/public/response-wall-page/","title":"Response Wall Page","text":""},{"location":"v2/frontend/pages/public/response-wall-page/#overview","title":"Overview","text":"<p>File Path: <code>admin/src/pages/public/ResponseWallPage.tsx</code> (492 lines)</p> <p>Route: <code>/responses/:campaignId</code></p> <p>Role Requirements: Public access (no authentication required)</p> <p>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.</p> <p>Key Features:</p> <ul> <li>Campaign-specific response display with back navigation</li> <li>Real-time statistics cards (Total Responses, Verified, Total Upvotes)</li> <li>Multi-criteria sorting (Recent, Most Upvoted, Verified Only)</li> <li>Government level filtering (Federal, Provincial, Municipal, All)</li> <li>Response cards with upvote functionality</li> <li>User comments and representative details</li> <li>Verification badges for confirmed responses</li> <li>Response submission modal with long-form input</li> <li>Pagination for large response sets</li> <li>Dark blue/teal theme consistency</li> <li>Mobile-responsive grid layout</li> </ul> <p>Layout: Uses <code>PublicLayout</code> component with dark theme</p>"},{"location":"v2/frontend/pages/public/response-wall-page/#features","title":"Features","text":""},{"location":"v2/frontend/pages/public/response-wall-page/#1-campaign-context-header","title":"1. Campaign Context Header","text":"<p>Navigation and campaign identification:</p> <ul> <li>Back Link: Returns to campaign detail page (<code>/campaigns/:campaignId</code>)</li> <li>Campaign Title: Displays as page heading</li> <li>Breadcrumb: \"Response Wall\" subtitle</li> <li>Icon: Comment icon for visual context</li> </ul>"},{"location":"v2/frontend/pages/public/response-wall-page/#2-statistics-dashboard","title":"2. Statistics Dashboard","text":"<p>Three key metrics displayed as cards:</p> <ul> <li>Total Responses: Count of all submissions (verified + unverified)</li> <li>Verified Responses: Count of email-verified submissions</li> <li>Total Upvotes: Aggregate upvote count across all responses</li> </ul> <p>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</p>"},{"location":"v2/frontend/pages/public/response-wall-page/#3-filtering-and-sorting-controls","title":"3. Filtering and Sorting Controls","text":"<p>User controls for response discovery:</p> <p>Sort Dropdown: - Recent: Newest first (default, <code>createdAt DESC</code>) - Most Upvoted: Highest upvote count first (<code>upvoteCount DESC</code>) - Verified Only: Only email-verified responses</p> <p>Government Level Filter: - All Levels: No filtering (default) - Federal: Federal government responses only - Provincial: Provincial/territorial responses only - Municipal: Municipal/local responses only</p> <p>Layout: - Row with two columns - Sort on left, filter on right - Full-width selects on mobile - Margin below for spacing</p>"},{"location":"v2/frontend/pages/public/response-wall-page/#4-response-cards","title":"4. Response Cards","text":"<p>Individual response display with rich metadata:</p> <p>Card Header: - User name (bold, 16px) - Timestamp (relative: \"2 hours ago\") - Verification badge (if <code>isVerified=true</code>)</p> <p>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)</p> <p>Card Footer: - Upvote button with count - Heart icon (filled if user upvoted) - Click toggles upvote status - Optimistic UI update</p> <p>Styling: - Dark background (<code>colorBgContainer</code>) - Rounded corners (8px) - Hover elevation shadow - Dividers between sections</p>"},{"location":"v2/frontend/pages/public/response-wall-page/#5-submit-response-modal","title":"5. Submit Response Modal","text":"<p>Long-form response submission interface:</p> <p>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)</p> <p>Validation: - Required field indicators - Email format validation - Min/max length checks (comment: 10-5000 chars) - Disabled submit until valid</p> <p>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: <code>unverified</code>) 6. Verification email sent if checkbox checked 7. Success modal displays 8. Form resets 9. Responses list refreshes</p>"},{"location":"v2/frontend/pages/public/response-wall-page/#6-pagination","title":"6. Pagination","text":"<p>Ant Design Pagination component:</p> <ul> <li>Page Size: 20 responses per page</li> <li>Total Count: Fetched from API</li> <li>Page Change: Triggers new API request</li> <li>Positioning: Centered below response grid</li> <li>Styling: Inherits dark theme from PublicLayout</li> </ul>"},{"location":"v2/frontend/pages/public/response-wall-page/#user-workflow","title":"User Workflow","text":""},{"location":"v2/frontend/pages/public/response-wall-page/#browsing-responses","title":"Browsing Responses","text":"<ol> <li>User arrives from campaign page via \"View Response Wall\" link</li> <li>Page loads responses (default: recent, all levels)</li> <li>User views statistics cards showing community engagement</li> <li>User scrolls through response cards</li> <li>User reads comments and representative details</li> <li>User upvotes responses they agree with</li> <li>User clicks pagination to view more responses</li> </ol>"},{"location":"v2/frontend/pages/public/response-wall-page/#filtering-and-sorting","title":"Filtering and Sorting","text":"<ol> <li>User selects \"Most Upvoted\" from sort dropdown</li> <li>API re-fetches responses with new sort order</li> <li>Grid updates with reordered responses</li> <li>User selects \"Federal\" from government level filter</li> <li>API re-fetches with government level filter</li> <li>Grid shows only federal responses</li> <li>User resets filters to \"All Levels\" to see everything</li> </ol>"},{"location":"v2/frontend/pages/public/response-wall-page/#submitting-a-response","title":"Submitting a Response","text":"<ol> <li>User clicks \"Submit Your Response\" button</li> <li>Modal opens with blank form</li> <li>User enters name: \"Jane Doe\"</li> <li>User enters email: \"jane@example.com\"</li> <li>User enters postal code: \"K1A 0B1\" (optional)</li> <li>User writes comment: \"I strongly support this bill because...\"</li> <li>User checks \"Email me a copy\" checkbox</li> <li>User clicks \"Submit Response\"</li> <li>API creates response with <code>isVerified=false</code></li> <li>Backend sends verification email to jane@example.com</li> <li>Success modal displays: \"Response submitted! Check your email to verify.\"</li> <li>User clicks \"OK\"</li> <li>Modal closes</li> <li>Responses grid refreshes (may not show new response if \"Verified Only\" filter active)</li> </ol>"},{"location":"v2/frontend/pages/public/response-wall-page/#upvoting","title":"Upvoting","text":"<ol> <li>User sees response they agree with</li> <li>User clicks heart icon button</li> <li>Optimistic update: upvote count increments, heart fills with color</li> <li>API request to <code>/api/public/responses/:id/upvote</code></li> <li>If API succeeds: update persists</li> <li>If API fails: revert to previous state, show error message</li> <li>User can click again to remove upvote (toggle behavior)</li> </ol>"},{"location":"v2/frontend/pages/public/response-wall-page/#component-structure","title":"Component Structure","text":"<pre><code>import React, { useState, useEffect } from 'react';\nimport { useParams, Link } from 'react-router-dom';\nimport {\n Card,\n Row,\n Col,\n Typography,\n Button,\n Select,\n Statistic,\n Modal,\n Form,\n Input,\n Checkbox,\n Pagination,\n Tag,\n Space,\n message,\n Grid\n} from 'antd';\nimport {\n ArrowLeftOutlined,\n CommentOutlined,\n HeartOutlined,\n HeartFilled,\n CheckCircleOutlined,\n TrophyOutlined,\n FireOutlined\n} from '@ant-design/icons';\nimport dayjs from 'dayjs';\nimport relativeTime from 'dayjs/plugin/relativeTime';\nimport PublicLayout from '../../components/PublicLayout';\nimport axios from 'axios';\n\ndayjs.extend(relativeTime);\n\nconst { Title, Paragraph, Text } = Typography;\nconst { TextArea } = Input;\nconst { Option } = Select;\nconst { useBreakpoint } = Grid;\n\ninterface Response {\n id: string;\n userName: string;\n userEmail: string;\n postalCode: string | null;\n comment: string;\n quotedText: string | null;\n isVerified: boolean;\n upvoteCount: number;\n representativeName: string;\n representativeDistrict: string;\n governmentLevel: string;\n createdAt: string;\n hasUpvoted?: boolean; // Client-side tracking\n}\n\ninterface Campaign {\n id: string;\n title: string;\n}\n\ninterface Stats {\n totalResponses: number;\n verifiedResponses: number;\n totalUpvotes: number;\n}\n\nconst ResponseWallPage: React.FC = () => {\n const { campaignId } = useParams<{ campaignId: string }>();\n const [responses, setResponses] = useState<Response[]>([]);\n const [campaign, setCampaign] = useState<Campaign | null>(null);\n const [stats, setStats] = useState<Stats>({ totalResponses: 0, verifiedResponses: 0, totalUpvotes: 0 });\n const [loading, setLoading] = useState(true);\n const [sortBy, setSortBy] = useState<string>('recent');\n const [governmentLevel, setGovernmentLevel] = useState<string>('all');\n const [page, setPage] = useState(1);\n const [total, setTotal] = useState(0);\n const [submitModalVisible, setSubmitModalVisible] = useState(false);\n const [form] = Form.useForm();\n const screens = useBreakpoint();\n const isMobile = !screens.md;\n\n const pageSize = 20;\n\n // Data fetching, handlers, etc.\n\n return (\n <PublicLayout>\n {/* Back link and title */}\n {/* Statistics cards */}\n {/* Sort and filter controls */}\n {/* Response cards grid */}\n {/* Pagination */}\n {/* Submit modal */}\n </PublicLayout>\n );\n};\n\nexport default ResponseWallPage;\n</code></pre>"},{"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":"<pre><code>// 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</code></pre>"},{"location":"v2/frontend/pages/public/response-wall-page/#derived-state","title":"Derived State","text":"<pre><code>// No complex derived state - filtering happens server-side\n// All data transformations done by API\n</code></pre>"},{"location":"v2/frontend/pages/public/response-wall-page/#state-flow","title":"State Flow","text":"<ol> <li>Initial Load: <code>loading=true</code>, fetch campaign + responses + stats</li> <li>Data Received: <code>setCampaign()</code>, <code>setResponses()</code>, <code>setStats()</code>, <code>setTotal()</code>, <code>loading=false</code></li> <li>Sort Changed: <code>setSortBy()</code>, <code>setPage(1)</code>, refetch responses</li> <li>Filter Changed: <code>setGovernmentLevel()</code>, <code>setPage(1)</code>, refetch responses</li> <li>Page Changed: <code>setPage()</code>, refetch responses (keep sort/filter)</li> <li>Upvote Clicked: Optimistic update to <code>responses</code> array, API call</li> <li>Submit Clicked: <code>setSubmitModalVisible(true)</code>, open form</li> <li>Response Submitted: API call, <code>setSubmitModalVisible(false)</code>, refetch responses</li> </ol>"},{"location":"v2/frontend/pages/public/response-wall-page/#api-integration","title":"API Integration","text":""},{"location":"v2/frontend/pages/public/response-wall-page/#endpoints-used","title":"Endpoints Used","text":""},{"location":"v2/frontend/pages/public/response-wall-page/#1-get-campaign-basic-info","title":"1. Get Campaign (Basic Info)","text":"<pre><code>GET /api/public/campaigns/:campaignId\n</code></pre> <p>Response: <pre><code>{\n \"id\": \"cm1abc123\",\n \"title\": \"Support Climate Action Bill\"\n}\n</code></pre></p>"},{"location":"v2/frontend/pages/public/response-wall-page/#2-get-response-statistics","title":"2. Get Response Statistics","text":"<pre><code>GET /api/public/responses/campaigns/:campaignId/stats\n</code></pre> <p>Response: <pre><code>{\n \"totalResponses\": 342,\n \"verifiedResponses\": 287,\n \"totalUpvotes\": 1829\n}\n</code></pre></p>"},{"location":"v2/frontend/pages/public/response-wall-page/#3-list-responses","title":"3. List Responses","text":"<pre><code>GET /api/public/responses/campaigns/:campaignId?page=1&limit=20&sortBy=recent&governmentLevel=all\n</code></pre> <p>Query Parameters: - <code>page</code>: Page number (1-indexed) - <code>limit</code>: Items per page (default 20, max 100) - <code>sortBy</code>: <code>recent</code> | <code>upvotes</code> | <code>verified</code> - <code>governmentLevel</code>: <code>all</code> | <code>federal</code> | <code>provincial</code> | <code>municipal</code></p> <p>Response: <pre><code>{\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</code></pre></p>"},{"location":"v2/frontend/pages/public/response-wall-page/#4-submit-response","title":"4. Submit Response","text":"<pre><code>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</code></pre> <p>Response: <pre><code>{\n \"success\": true,\n \"responseId\": \"cm2def456\",\n \"message\": \"Response submitted successfully. Please check your email to verify.\"\n}\n</code></pre></p>"},{"location":"v2/frontend/pages/public/response-wall-page/#5-upvote-response","title":"5. Upvote Response","text":"<pre><code>POST /api/public/responses/:id/upvote\n</code></pre> <p>Response: <pre><code>{\n \"success\": true,\n \"upvoteCount\": 48,\n \"action\": \"added\"\n}\n</code></pre></p> <p>Note: Second request to same endpoint toggles (removes upvote), returns <code>\"action\": \"removed\"</code>.</p>"},{"location":"v2/frontend/pages/public/response-wall-page/#request-examples","title":"Request Examples","text":""},{"location":"v2/frontend/pages/public/response-wall-page/#fetch-responses","title":"Fetch Responses","text":"<pre><code>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</code></pre>"},{"location":"v2/frontend/pages/public/response-wall-page/#submit-response","title":"Submit Response","text":"<pre><code>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</code></pre>"},{"location":"v2/frontend/pages/public/response-wall-page/#upvote-response","title":"Upvote Response","text":"<pre><code>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</code></pre>"},{"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":"<pre><code><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</code></pre>"},{"location":"v2/frontend/pages/public/response-wall-page/#sort-and-filter-controls","title":"Sort and Filter Controls","text":"<pre><code><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</code></pre>"},{"location":"v2/frontend/pages/public/response-wall-page/#response-cards","title":"Response Cards","text":"<pre><code><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</code></pre>"},{"location":"v2/frontend/pages/public/response-wall-page/#submit-response-modal","title":"Submit Response Modal","text":"<pre><code><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</code></pre>"},{"location":"v2/frontend/pages/public/response-wall-page/#pagination","title":"Pagination","text":"<pre><code>{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</code></pre>"},{"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":"<p>Campaign, stats, and responses fetched simultaneously:</p> <pre><code>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</code></pre> <p>Benefit: Reduces initial load time by ~60% vs sequential requests.</p>"},{"location":"v2/frontend/pages/public/response-wall-page/#2-optimistic-upvote-updates","title":"2. Optimistic Upvote Updates","text":"<p>UI updates immediately before API confirmation:</p> <pre><code>// 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</code></pre> <p>Benefit: Perceived performance improvement, instant feedback.</p>"},{"location":"v2/frontend/pages/public/response-wall-page/#3-server-side-filtering","title":"3. Server-Side Filtering","text":"<p>All filtering/sorting done via API query params (not client-side):</p> <pre><code>params: {\n sortBy,\n governmentLevel: governmentLevel === 'all' ? undefined : governmentLevel\n}\n</code></pre> <p>Benefit: Scalable to thousands of responses, no client memory issues.</p>"},{"location":"v2/frontend/pages/public/response-wall-page/#4-pagination","title":"4. Pagination","text":"<p>Limited to 20 responses per page:</p> <pre><code>const pageSize = 20;\n</code></pre> <p>Benefit: Reduces DOM nodes, faster render, better mobile performance.</p>"},{"location":"v2/frontend/pages/public/response-wall-page/#5-scroll-to-top-on-page-change","title":"5. Scroll to Top on Page Change","text":"<p>Smooth scroll when pagination changes:</p> <pre><code>onChange={(newPage) => {\n setPage(newPage);\n window.scrollTo({ top: 0, behavior: 'smooth' });\n}}\n</code></pre> <p>Benefit: Better UX, user doesn't miss new content.</p>"},{"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":"<p>Statistics Cards: - Stack vertically on xs (easier to scan) - Show 3 columns on sm+ (compact display) - Font size remains large (32px) for impact</p> <p>Response Cards: - Always full-width (xs=24) - Better readability on narrow screens - Upvote button full-width on mobile (future enhancement)</p> <p>Sort/Filter Controls: - Stack vertically on xs (full-width selects) - Side-by-side on sm+ (50% width each) - Labels above selects for clarity</p> <p>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)</p>"},{"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":"<p>Response Cards: - Upvote button focusable via Tab - Enter/Space toggles upvote</p> <p>Sort/Filter Controls: - Dropdowns keyboard navigable (Arrow keys + Enter) - Focus visible on all select elements</p> <p>Pagination: - Page numbers focusable - Arrow keys navigate pages (native Ant Design)</p>"},{"location":"v2/frontend/pages/public/response-wall-page/#aria-labels","title":"ARIA Labels","text":"<p>Upvote Button: <pre><code><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</code></pre></p> <p>Statistics Cards: <pre><code><Statistic\n title=\"Total Responses\"\n value={stats.totalResponses}\n aria-label={`Total responses: ${stats.totalResponses}`}\n/>\n</code></pre></p> <p>Modal: <pre><code><Modal\n title=\"Submit Your Response\"\n aria-labelledby=\"submit-response-title\"\n aria-describedby=\"submit-response-description\"\n>\n</code></pre></p>"},{"location":"v2/frontend/pages/public/response-wall-page/#screen-reader-support","title":"Screen Reader Support","text":"<p>Verification Badge: <pre><code><Tag color=\"green\" icon={<CheckCircleOutlined />}>\n <span aria-label=\"Email verified\">Verified</span>\n</Tag>\n</code></pre></p> <p>Timestamp: <pre><code><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</code></pre></p> <p>Form Validation: - Error messages announced automatically - Required field indicators (<code>required</code> attribute) - Help text linked via <code>aria-describedby</code></p>"},{"location":"v2/frontend/pages/public/response-wall-page/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/frontend/pages/public/response-wall-page/#issue-upvotes-not-persisting","title":"Issue: Upvotes Not Persisting","text":"<p>Symptoms: - User clicks upvote, count increments - Page refresh resets upvote - Heart icon reverts to outline</p> <p>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</p> <p>Solutions:</p> <pre><code>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</code></pre> <p>Check backend upvote tracking: <pre><code>-- Verify upvote records created\nSELECT * FROM \"ResponseUpvote\"\nWHERE \"responseId\" = 'cm2abc123'\nORDER BY \"createdAt\" DESC;\n</code></pre></p>"},{"location":"v2/frontend/pages/public/response-wall-page/#issue-statistics-not-updating-after-submission","title":"Issue: Statistics Not Updating After Submission","text":"<p>Symptoms: - User submits response - Response appears in list - Statistics cards show old counts</p> <p>Causes: 1. Stats fetched once on mount, never refreshed 2. New response not included in stats query 3. Cache invalidation not working</p> <p>Solutions:</p> <pre><code>// 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</code></pre>"},{"location":"v2/frontend/pages/public/response-wall-page/#issue-verified-only-filter-shows-no-results","title":"Issue: \"Verified Only\" Filter Shows No Results","text":"<p>Symptoms: - User selects \"Verified Only\" sort - Grid shows empty state - Total count remains high</p> <p>Causes: 1. No verified responses exist yet 2. API not filtering correctly 3. Frontend not passing correct param</p> <p>Solutions:</p> <pre><code>// 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</code></pre> <p>Check backend: <pre><code>-- Count verified vs unverified\nSELECT \"isVerified\", COUNT(*)\nFROM \"Response\"\nWHERE \"campaignId\" = 'cm1abc123'\nGROUP BY \"isVerified\";\n</code></pre></p>"},{"location":"v2/frontend/pages/public/response-wall-page/#issue-pagination-showing-wrong-total","title":"Issue: Pagination Showing Wrong Total","text":"<p>Symptoms: - Pagination shows \"1-20 of 342\" - Only 50 total responses exist - Total count doesn't match stats card</p> <p>Causes: 1. Stats query counting all campaigns 2. Responses query filtering by campaign correctly 3. Stats API endpoint broken</p> <p>Solutions:</p> <pre><code>// 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</code></pre>"},{"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":"<ul> <li>Campaigns List Page - Campaign directory</li> <li>Campaign Detail Page - Email sending workflow</li> <li>Map Page - Public location mapping</li> </ul>"},{"location":"v2/frontend/pages/public/response-wall-page/#admin-pages","title":"Admin Pages","text":"<ul> <li>Response Moderation - Admin moderation tools</li> <li>Campaigns Management - Campaign configuration</li> </ul>"},{"location":"v2/frontend/pages/public/response-wall-page/#components","title":"Components","text":"<ul> <li>PublicLayout - Dark theme wrapper</li> <li>ShareButtons - Social sharing</li> </ul>"},{"location":"v2/frontend/pages/public/response-wall-page/#api-documentation","title":"API Documentation","text":"<ul> <li>Public Responses API</li> <li>Response Upvoting</li> </ul>"},{"location":"v2/frontend/pages/public/response-wall-page/#architecture","title":"Architecture","text":"<ul> <li>Response Verification System</li> <li>Email Templates</li> </ul>"},{"location":"v2/frontend/pages/public/shifts-page/","title":"Public Shifts Page","text":""},{"location":"v2/frontend/pages/public/shifts-page/#overview","title":"Overview","text":"<p>File Path: <code>admin/src/pages/public/ShiftsPage.tsx</code> (344 lines)</p> <p>Route: <code>/shifts</code></p> <p>Role Requirements: Public access (no authentication required)</p> <p>Purpose: Public volunteer shift signup interface allowing community members to register for canvassing shifts, creating temporary user accounts automatically, and receiving email confirmations.</p> <p>Key Features:</p> <ul> <li>Hero banner with gradient background</li> <li>Responsive shift cards grid (xs=1, sm=2, lg=3 columns)</li> <li>Real-time volunteer capacity progress bars</li> <li>Signup modal with name/email/phone fields</li> <li>Temporary user creation for non-authenticated signups</li> <li>Email confirmation after successful signup</li> <li>Success modal with shift details</li> <li>Visual opacity indication for full shifts</li> <li>Dark theme consistency with public pages</li> </ul> <p>Layout: Uses <code>PublicLayout</code> with dark theme</p>"},{"location":"v2/frontend/pages/public/shifts-page/#features","title":"Features","text":""},{"location":"v2/frontend/pages/public/shifts-page/#1-hero-banner","title":"1. Hero Banner","text":"<p>Prominent call-to-action header:</p> <ul> <li>Gradient Background: Purple-to-blue gradient</li> <li>Title: \"Volunteer Opportunities\"</li> <li>Subtitle: \"Join us in making a difference in your community\"</li> <li>Icon: Calendar icon</li> <li>Padding: 80px vertical (desktop), 60px (mobile)</li> </ul>"},{"location":"v2/frontend/pages/public/shifts-page/#2-shift-cards-grid","title":"2. Shift Cards Grid","text":"<p>Responsive grid displaying available shifts:</p> <p>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)</p> <p>Styling: - Dark card background (<code>colorBgContainer</code>) - Hover elevation effect - 24px gutter between cards - Rounded corners (8px)</p> <p>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\"</p> <p>Full Shifts: - Card opacity reduced to 0.6 - Button disabled with \"Full\" text - Badge showing \"Full\" in red</p>"},{"location":"v2/frontend/pages/public/shifts-page/#3-signup-modal","title":"3. Signup Modal","text":"<p>User registration form:</p> <p>Form Fields: - Your Name (required, min 2 chars) - Email (required, email validation) - Phone (required, phone number format)</p> <p>Shift Details Display: - Shift title (read-only) - Date/time (read-only) - Location (read-only)</p> <p>Submission: - Creates temporary user if not logged in - Creates shift signup record - Sends confirmation email - Opens success modal</p> <p>Validation: - Required field indicators - Email format check - Phone format (10 digits, (XXX) XXX-XXXX) - Duplicate signup prevention</p>"},{"location":"v2/frontend/pages/public/shifts-page/#4-success-modal","title":"4. Success Modal","text":"<p>Post-signup confirmation:</p> <p>Content: - Green checkmark icon - \"Successfully Signed Up!\" heading - Shift details (title, date, time, location) - Email confirmation message - \"OK\" button to close</p> <p>Behavior: - Auto-opens after successful signup - Reloads shift list on close (to show updated capacity)</p>"},{"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":"<ol> <li>User navigates to <code>/shifts</code></li> <li>Hero banner loads with CTA</li> <li>API fetches active shifts</li> <li>Shift cards render in grid</li> <li>User sees capacity bars (green/yellow/red)</li> <li>User scrolls through available shifts</li> </ol>"},{"location":"v2/frontend/pages/public/shifts-page/#signing-up-for-shift","title":"Signing Up for Shift","text":"<ol> <li>User finds desired shift card</li> <li>User clicks \"Sign Up\" button</li> <li>Modal opens with signup form</li> <li>User enters name: \"Jane Doe\"</li> <li>User enters email: \"jane@example.com\"</li> <li>User enters phone: \"(555) 123-4567\"</li> <li>User clicks \"Sign Up\" submit button</li> <li>API creates temp user (role: TEMP)</li> <li>API creates shift signup</li> <li>Confirmation email sent</li> <li>Success modal displays</li> <li>User clicks \"OK\"</li> <li>Modal closes</li> <li>Shift list refreshes</li> <li>Signed-up shift shows updated capacity</li> </ol>"},{"location":"v2/frontend/pages/public/shifts-page/#full-shift-handling","title":"Full Shift Handling","text":"<ol> <li>User sees shift with red progress bar (full)</li> <li>Card has reduced opacity</li> <li>Button shows \"Full\" and is disabled</li> <li>User cannot click signup</li> <li>\"Full\" badge visible on card</li> </ol>"},{"location":"v2/frontend/pages/public/shifts-page/#component-structure","title":"Component Structure","text":"<pre><code>import React, { useState, useEffect } from 'react';\nimport { Card, Row, Col, Typography, Button, Form, Input, Modal, Progress, Tag, Grid, message } from 'antd';\nimport { CalendarOutlined, CheckCircleOutlined, ClockCircleOutlined, EnvironmentOutlined } from '@ant-design/icons';\nimport dayjs from 'dayjs';\nimport PublicLayout from '../../components/PublicLayout';\nimport axios from 'axios';\n\nconst { Title, Paragraph, Text } = Typography;\nconst { useBreakpoint } = Grid;\n\ninterface Shift {\n id: string;\n title: string;\n description: string | null;\n date: string;\n startTime: string;\n endTime: string;\n location: string;\n maxVolunteers: number;\n currentSignups: number;\n cutName: string | null;\n}\n\nconst ShiftsPage: React.FC = () => {\n const [shifts, setShifts] = useState<Shift[]>([]);\n const [loading, setLoading] = useState(true);\n const [signupModalVisible, setSignupModalVisible] = useState(false);\n const [selectedShift, setSelectedShift] = useState<Shift | null>(null);\n const [form] = Form.useForm();\n const screens = useBreakpoint();\n const isMobile = !screens.md;\n\n return (\n <PublicLayout>\n {/* Hero Banner */}\n {/* Shift Cards Grid */}\n {/* Signup Modal */}\n {/* Success Modal */}\n </PublicLayout>\n );\n};\n</code></pre>"},{"location":"v2/frontend/pages/public/shifts-page/#state-management","title":"State Management","text":"<pre><code>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</code></pre>"},{"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":"<pre><code>GET /api/public/map/shifts\n</code></pre> <p>Response: <pre><code>[\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</code></pre></p>"},{"location":"v2/frontend/pages/public/shifts-page/#2-sign-up-for-shift","title":"2. Sign Up for Shift","text":"<pre><code>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</code></pre> <p>Response: <pre><code>{\n \"success\": true,\n \"signupId\": \"cm2def456\",\n \"message\": \"Successfully signed up! Confirmation email sent.\"\n}\n</code></pre></p>"},{"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":"<pre><code>{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</code></pre>"},{"location":"v2/frontend/pages/public/shifts-page/#signup-modal","title":"Signup Modal","text":"<pre><code><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</code></pre>"},{"location":"v2/frontend/pages/public/shifts-page/#performance-considerations","title":"Performance Considerations","text":"<ol> <li>Single API Call: All shifts fetched once on mount</li> <li>Optimistic UI: Capacity updates immediately after signup</li> <li>Form Reset: Clears fields after successful submission</li> <li>Debounced Validation: Email/phone validation on blur, not keystroke</li> </ol>"},{"location":"v2/frontend/pages/public/shifts-page/#accessibility","title":"Accessibility","text":"<ul> <li>Keyboard Navigation: All buttons focusable</li> <li>Form Labels: Associated with inputs via htmlFor</li> <li>Progress Bars: Include sr-only text for screen readers</li> <li>Color Contrast: All text meets WCAG AA standards</li> </ul>"},{"location":"v2/frontend/pages/public/shifts-page/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/frontend/pages/public/shifts-page/#issue-phone-validation-failing","title":"Issue: Phone Validation Failing","text":"<p>Solution: <pre><code>// 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</code></pre></p>"},{"location":"v2/frontend/pages/public/shifts-page/#related-documentation","title":"Related Documentation","text":"<ul> <li>Admin Shifts Page</li> <li>Volunteer Shifts Page</li> <li>Shift Signups API</li> </ul>"},{"location":"v2/frontend/pages/volunteer/","title":"Volunteer Pages","text":"<p>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.</p>"},{"location":"v2/frontend/pages/volunteer/#route-context","title":"Route Context","text":"<ul> <li>Prefix: <code>/volunteer/*</code></li> <li>Layout: VolunteerLayout (top navigation)</li> <li>Auth Required: Yes (any role)</li> <li>Theme: Dark theme (consistent with public pages)</li> <li>Mobile: Optimized for mobile/tablet use</li> </ul>"},{"location":"v2/frontend/pages/volunteer/#dashboard","title":"Dashboard","text":""},{"location":"v2/frontend/pages/volunteer/#volunteer-dashboard","title":"Volunteer Dashboard","text":"<p>Route: <code>/volunteer/dashboard</code></p> <p>Volunteer overview with:</p> <ul> <li>Personal statistics</li> <li>Upcoming assignments</li> <li>Recent activity</li> <li>Quick actions</li> </ul> <p>Features: - Visit count and outcomes - Next shift information - Activity summary - Mobile responsive</p>"},{"location":"v2/frontend/pages/volunteer/#shift-management","title":"Shift Management","text":""},{"location":"v2/frontend/pages/volunteer/#volunteer-shifts-page","title":"Volunteer Shifts Page","text":"<p>Route: <code>/volunteer/assignments</code></p> <p>Assigned shifts for logged-in volunteer:</p> <ul> <li>Upcoming shifts list</li> <li>Cut information</li> <li>Start canvass button</li> <li>Shift details</li> </ul> <p>Features: - Filter by date - Cut assignment display - Quick start canvassing - Email notifications - Mobile responsive</p> <p>Note: Only shows shifts with <code>cutId</code> assigned (required for canvassing).</p>"},{"location":"v2/frontend/pages/volunteer/#canvassing","title":"Canvassing","text":""},{"location":"v2/frontend/pages/volunteer/#volunteer-map-page","title":"Volunteer Map Page","text":"<p>Route: <code>/volunteer/canvass/:cutId</code></p> <p>Full-screen GPS canvass map:</p> <ul> <li>Leaflet map with GPS tracking</li> <li>Location markers by status</li> <li>Walking route polyline</li> <li>Bottom sheet toolbar</li> <li>Visit recording form</li> <li>Session timer</li> </ul> <p>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</p> <p>Full-Screen: - No layout wrapper - Custom header with timer - Bottom sheet controls - Optimized for field use</p> <p>GPS Tracking: - Watch position API - Blue GPS marker - Accuracy circle - Auto-update every 5 seconds</p> <p>Visit Recording: - Outcome selection (NOT_HOME, MOVED, REFUSED, etc.) - Notes field - GPS coordinates captured - Rate limited (30/min)</p> <p>Session Management: - Start/end session - Elapsed timer - Abandoned session cleanup (12h) - Progress tracking</p>"},{"location":"v2/frontend/pages/volunteer/#activity-tracking","title":"Activity Tracking","text":""},{"location":"v2/frontend/pages/volunteer/#my-activity-page","title":"My Activity Page","text":"<p>Route: <code>/volunteer/activity</code></p> <p>Visit history and statistics:</p> <ul> <li>Visit list by date</li> <li>Outcome breakdown</li> <li>Session summary</li> <li>Export options</li> </ul> <p>Features: - Filter by date range - Group by session - Outcome pie chart - Total visit count - Mobile responsive</p>"},{"location":"v2/frontend/pages/volunteer/#my-routes-page","title":"My Routes Page","text":"<p>Route: <code>/volunteer/routes</code></p> <p>Walking route history:</p> <ul> <li>Route list by session</li> <li>Distance traveled</li> <li>Time elapsed</li> <li>Location count</li> </ul> <p>Features: - Route visualization - Session details - Statistics summary - Mobile responsive</p>"},{"location":"v2/frontend/pages/volunteer/#volunteer-page-count","title":"Volunteer Page Count","text":"<p>Total: 4 volunteer pages</p>"},{"location":"v2/frontend/pages/volunteer/#common-features","title":"Common Features","text":"<p>Volunteer pages share:</p> <ul> <li>Mobile First - Touch-friendly controls</li> <li>GPS Integration - Location tracking</li> <li>Dark Theme - Low-light visibility</li> <li>Session State - Zustand canvass store</li> <li>Real-time Updates - Auto-refresh data</li> <li>Offline Support (future) - Service worker caching</li> </ul>"},{"location":"v2/frontend/pages/volunteer/#authentication","title":"Authentication","text":"<p>All volunteer pages require authentication:</p> <pre><code>// Volunteer routes use authenticate middleware\nrouter.get('/api/canvass/session', authenticate, ...)\n</code></pre> <p>Role-based redirect after login: - ADMIN roles \u2192 <code>/app/dashboard</code> - USER/TEMP roles \u2192 <code>/volunteer/dashboard</code></p>"},{"location":"v2/frontend/pages/volunteer/#state-management","title":"State Management","text":"<p>Volunteer pages use Zustand canvass store:</p> <pre><code>// 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</code></pre>"},{"location":"v2/frontend/pages/volunteer/#gps-tracking","title":"GPS Tracking","text":"<p>GPS tracking uses browser Geolocation API:</p> <pre><code>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</code></pre>"},{"location":"v2/frontend/pages/volunteer/#walking-route-algorithm","title":"Walking Route Algorithm","text":"<p>Routes are calculated server-side using nearest-neighbor algorithm:</p> <ol> <li>Start at closest location to shift start</li> <li>For each subsequent location:</li> <li>Find nearest unvisited location</li> <li>Add to route</li> <li>Return ordered location list</li> </ol> <p>Frontend displays route as blue polyline connecting locations.</p>"},{"location":"v2/frontend/pages/volunteer/#visit-outcomes","title":"Visit Outcomes","text":"<p>Available outcomes in recording form:</p> <ul> <li>SUCCESS - Successful contact</li> <li>NOT_HOME - No one home</li> <li>MOVED - Resident moved</li> <li>REFUSED - Contact refused</li> <li>WRONG_ADDRESS - Address error</li> <li>INACCESSIBLE - Cannot access</li> <li>OTHER - Other outcome</li> </ul>"},{"location":"v2/frontend/pages/volunteer/#session-lifecycle","title":"Session Lifecycle","text":"<ol> <li>Start Session - Create session record, generate route</li> <li>GPS Tracking - Track position, update markers</li> <li>Visit Locations - Record outcomes, update route</li> <li>End Session - Close session, save statistics</li> <li>Abandoned Cleanup - Auto-close after 12 hours</li> </ol>"},{"location":"v2/frontend/pages/volunteer/#mobile-optimization","title":"Mobile Optimization","text":"<p>Volunteer pages are optimized for mobile:</p> <ul> <li>Touch Targets - Minimum 44px touch areas</li> <li>GPS Integration - Native geolocation</li> <li>Offline Maps (future) - Cached tiles</li> <li>Battery Optimization - Efficient GPS polling</li> <li>Low-Light Mode - Dark theme</li> <li>Network Resilience - Queue failed requests</li> </ul>"},{"location":"v2/frontend/pages/volunteer/#performance","title":"Performance","text":"<p>GPS canvass map optimizations:</p> <ul> <li>Marker clustering for large datasets</li> <li>Route simplification</li> <li>Debounced position updates</li> <li>Lazy loading location details</li> <li>Service worker caching (future)</li> </ul>"},{"location":"v2/frontend/pages/volunteer/#related-documentation","title":"Related Documentation","text":"<ul> <li>Frontend Pages Overview</li> <li>Admin Pages</li> <li>Public Pages</li> <li>Volunteer Layout</li> <li>Canvass Components</li> <li>Canvassing Feature</li> <li>Backend Canvass Module</li> <li>Volunteer User Guide</li> </ul>"},{"location":"v2/frontend/pages/volunteer/my-activity-page/","title":"My Activity Page","text":""},{"location":"v2/frontend/pages/volunteer/my-activity-page/#overview","title":"Overview","text":"<p>File Path: <code>admin/src/pages/volunteer/MyActivityPage.tsx</code> (137 lines)</p> <p>Route: <code>/volunteer/activity</code></p> <p>Role Requirements: Authenticated users</p> <p>Purpose: Volunteer activity dashboard showing canvassing statistics and visit history.</p> <p>Key Features:</p> <ul> <li>Statistics cards (Today's Visits, Total Doors, Total Sessions)</li> <li>Outcome breakdown with color-coded tags</li> <li>Visit history table (address, outcome, timestamp)</li> <li>Pagination (20 visits per page)</li> <li>Parallel stats + visits fetch</li> <li>Dark theme (VolunteerLayout)</li> </ul>"},{"location":"v2/frontend/pages/volunteer/my-activity-page/#features","title":"Features","text":""},{"location":"v2/frontend/pages/volunteer/my-activity-page/#1-statistics-cards","title":"1. Statistics Cards","text":"<pre><code><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</code></pre>"},{"location":"v2/frontend/pages/volunteer/my-activity-page/#2-outcome-breakdown","title":"2. Outcome Breakdown","text":"<pre><code><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</code></pre>"},{"location":"v2/frontend/pages/volunteer/my-activity-page/#3-visit-history-table","title":"3. Visit History Table","text":"<pre><code><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</code></pre>"},{"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":"<pre><code>GET /api/map/canvass/my-stats\nAuthorization: Bearer {token}\n</code></pre> <p>Response: <pre><code>{\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</code></pre></p>"},{"location":"v2/frontend/pages/volunteer/my-activity-page/#2-get-visit-history","title":"2. Get Visit History","text":"<pre><code>GET /api/map/canvass/my-visits?page=1&limit=20\nAuthorization: Bearer {token}\n</code></pre> <p>Response: <pre><code>{\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</code></pre></p>"},{"location":"v2/frontend/pages/volunteer/my-activity-page/#related-documentation","title":"Related Documentation","text":"<ul> <li>Volunteer Map Page</li> <li>My Routes Page</li> <li>Canvass Dashboard</li> </ul>"},{"location":"v2/frontend/pages/volunteer/my-routes-page/","title":"My Routes Page","text":""},{"location":"v2/frontend/pages/volunteer/my-routes-page/#overview","title":"Overview","text":"<p>File Path: <code>admin/src/pages/volunteer/MyRoutesPage.tsx</code> (275 lines)</p> <p>Route: <code>/volunteer/routes</code></p> <p>Role Requirements: Authenticated users</p> <p>Purpose: Visual display of volunteer's canvassing routes with map visualization and session history.</p> <p>Key Features:</p> <ul> <li>Statistics cards (Total Sessions, Total Distance, Total Time)</li> <li>Interactive map with route polyline</li> <li>Color-coded event markers (Session Start/End, Visit, Location Added)</li> <li>Legend for event types</li> <li>Session history table (date, duration, distance, point count)</li> <li>View/Hide route toggle button</li> <li>FitBounds component to center map on route</li> <li>Dark CARTO basemap</li> </ul>"},{"location":"v2/frontend/pages/volunteer/my-routes-page/#features","title":"Features","text":""},{"location":"v2/frontend/pages/volunteer/my-routes-page/#1-statistics-summary","title":"1. Statistics Summary","text":"<pre><code><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</code></pre>"},{"location":"v2/frontend/pages/volunteer/my-routes-page/#2-route-map","title":"2. Route Map","text":"<pre><code><MapContainer\n center={[45.5017, -73.5673]}\n zoom={13}\n style={{ height: 400 }}\n>\n <TileLayer\n url=\"https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png\"\n attribution='&copy; <a href=\"https://carto.com/\">CARTO</a>'\n />\n\n {/* Route polyline */}\n {routeVisible && selectedRoute && (\n <Polyline\n positions={selectedRoute.points.map(p => [p.latitude, p.longitude])}\n pathOptions={{ color: '#1890ff', weight: 3 }}\n />\n )}\n\n {/* Event markers */}\n {selectedRoute?.points.map(point => (\n <CircleMarker\n key={point.id}\n center={[point.latitude, point.longitude]}\n radius={6}\n pathOptions={{\n color: getEventColor(point.eventType),\n fillColor: getEventColor(point.eventType),\n fillOpacity: 1\n }}\n >\n <Popup>\n <Text strong>{point.eventType}</Text>\n <br />\n <Text type=\"secondary\">\n {dayjs(point.timestamp).format('h:mm:ss A')}\n </Text>\n </Popup>\n </CircleMarker>\n ))}\n\n {/* Fit bounds to route */}\n {selectedRoute && <FitBounds points={selectedRoute.points} />}\n</MapContainer>\n</code></pre>"},{"location":"v2/frontend/pages/volunteer/my-routes-page/#3-event-type-legend","title":"3. Event Type Legend","text":"<pre><code><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</code></pre>"},{"location":"v2/frontend/pages/volunteer/my-routes-page/#4-session-history-table","title":"4. Session History Table","text":"<pre><code><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</code></pre>"},{"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":"<pre><code>GET /api/map/canvass/my-routes/stats\nAuthorization: Bearer {token}\n</code></pre> <p>Response: <pre><code>{\n \"totalSessions\": 12,\n \"totalDistance\": 34567,\n \"totalDuration\": 18900\n}\n</code></pre></p>"},{"location":"v2/frontend/pages/volunteer/my-routes-page/#2-get-session-routes","title":"2. Get Session Routes","text":"<pre><code>GET /api/map/canvass/my-routes\nAuthorization: Bearer {token}\n</code></pre> <p>Response: <pre><code>[\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</code></pre></p>"},{"location":"v2/frontend/pages/volunteer/my-routes-page/#utility-functions","title":"Utility Functions","text":"<pre><code>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</code></pre>"},{"location":"v2/frontend/pages/volunteer/my-routes-page/#related-documentation","title":"Related Documentation","text":"<ul> <li>My Activity Page</li> <li>Volunteer Map Page</li> <li>GPS Tracking System</li> </ul>"},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/","title":"Volunteer Map Page","text":""},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#overview","title":"Overview","text":"<p>File Path: <code>admin/src/pages/volunteer/VolunteerMapPage.tsx</code> (570 lines)</p> <p>Route: <code>/volunteer/canvass/:cutId</code></p> <p>Role Requirements: Authenticated users (USER or TEMP role)</p> <p>Purpose: Full-screen GPS-enabled canvassing map for door-to-door volunteer work with visit recording, route navigation, location management, and session tracking.</p> <p>Key Features:</p> <ul> <li>Full-screen map (no AppLayout wrapper)</li> <li>Real-time GPS tracking with following mode</li> <li>Canvass session management (start/end with auto-pause)</li> <li>Walking route display with toggle</li> <li>CanvassMarkerGroup for clustered address display</li> <li>VisitRecordingForm in bottom drawer</li> <li>AddLocationDrawer (crosshair + tap to add)</li> <li>VolunteerMapDrawer (menu, stats, session picker)</li> <li>VolunteerFooterNav (bottom navigation bar)</li> <li>VolunteerSessionBar (active session indicator above footer)</li> <li>TileLayerToggle (OpenStreetMap, CARTO, Satellite)</li> <li>AddressSearchOverlay</li> <li>Next door button (finds nearest unvisited location)</li> <li>Cut polygon overlays with toggle controls</li> <li>Admin edit mode (LocationEditDrawer for MAP_ADMIN users)</li> <li>Tracking integration (links to GPS tracking sessions)</li> </ul> <p>Layout: No layout wrapper - full viewport with custom overlays</p>"},{"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":"<p>Real-time position tracking with following mode:</p> <p>Components: - <code>GPSTracker</code> 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)</p> <p>Code: <pre><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</code></pre></p>"},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#2-session-management","title":"2. Session Management","text":"<p>Canvass session lifecycle:</p> <p>States: - ACTIVE: Session in progress - PAUSED: Session paused (GPS stopped) - ENDED: Session completed</p> <p>Controls: - Start Session button (green) - Pause Session button (yellow) - End Session button (red, with confirmation) - Auto-pause after 30min inactivity</p> <p>Session Bar: <pre><code><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</code></pre></p>"},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#3-walking-route-display","title":"3. Walking Route Display","text":"<p>Optimized door-to-door route:</p> <p>Algorithm: - Nearest neighbor with deduplication - Starts from user's current position - Visits unvisited locations in order - Avoids backtracking</p> <p>Visual: - Blue polyline connecting locations - Dashed line style - Toggle button to show/hide - Route recalculates when locations visited</p> <p>Code: <pre><code>{routeVisible && walkingRoute && (\n <WalkingRouteLine\n route={walkingRoute}\n userPosition={userPosition}\n />\n)}\n</code></pre></p>"},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#4-canvass-markers","title":"4. Canvass Markers","text":"<p>Location markers with clustering:</p> <p>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</p> <p>Marker States: - Unvisited: Gray circle - Visited: Color-coded by outcome - Selected: Larger radius + pulsing animation</p>"},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#5-visit-recording-form","title":"5. Visit Recording Form","text":"<p>Bottom drawer for recording visits:</p> <p>Fields: - Address (read-only, pre-filled) - Outcome (dropdown: 7 options) - Notes (TextArea, optional) - Contact Interest (checkbox) - Follow-up Required (checkbox)</p> <p>Outcome Options: 1. Strong Support 2. Leaning Support 3. Undecided 4. Leaning Opposed 5. Opposed 6. No Answer 7. Not Home</p> <p>Submission: - Creates CanvassVisit record - Updates location supportLevel - Closes drawer - Marker updates color - Next door button finds new nearest</p> <p>Code: <pre><code><VisitRecordingForm\n location={selectedLocation}\n sessionId={activeSession?.id}\n visible={recordingDrawerVisible}\n onClose={() => setRecordingDrawerVisible(false)}\n onSubmit={handleVisitSubmit}\n/>\n</code></pre></p>"},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#6-add-location-mode","title":"6. Add Location Mode","text":"<p>Crosshair interface for adding missing addresses:</p> <p>Activation: - \"Add Location\" button in menu - Opens AddLocationDrawer - Crosshair appears at map center - User pans map to position crosshair - \"Tap Here to Add\" button</p> <p>AddLocationDrawer: - Address input (with geocoding suggestion) - Unit number (for multi-unit buildings) - Notes - Cancel / Confirm buttons</p> <p>Code: <pre><code><AddLocationDrawer\n visible={addLocationMode}\n position={map.getCenter()}\n onConfirm={handleAddLocation}\n onCancel={() => setAddLocationMode(false)}\n/>\n</code></pre></p>"},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#7-map-controls","title":"7. Map Controls","text":"<p>Floating control panels:</p> <p>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</p> <p>Control Buttons (right side): - Geolocate (find my location) - Toggle walking route - Next door (find nearest unvisited) - Fullscreen toggle</p>"},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#8-tile-layer-toggle","title":"8. Tile Layer Toggle","text":"<p>Three basemap options:</p> <ol> <li>OpenStreetMap: Default, detailed streets</li> <li>CARTO Dark: High contrast, good for day/night</li> <li>Satellite: Aerial imagery from Esri</li> </ol> <p>Component: <pre><code><TileLayerToggle\n activeLayer={activeLayer}\n onChange={setActiveLayer}\n position=\"topright\"\n/>\n</code></pre></p>"},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#9-address-search-overlay","title":"9. Address Search Overlay","text":"<p>Quick location lookup:</p> <p>Features: - Input with search icon - Autocomplete from locations in cut - Fly to location on select - Opens visit recording form</p> <p>Code: <pre><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</code></pre></p>"},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#10-next-door-button","title":"10. Next Door Button","text":"<p>Intelligent location finder:</p> <p>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</p> <p>Code: <pre><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</code></pre></p>"},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#11-admin-edit-mode","title":"11. Admin Edit Mode","text":"<p>MAP_ADMIN users can edit locations:</p> <p>Features: - Edit button on location popup - LocationEditDrawer with full form - Update address, support level, notes - Delete location (with confirmation) - Move location (drag marker)</p> <p>Conditional Render: <pre><code>{user?.role === 'MAP_ADMIN' && (\n <Button onClick={() => setEditMode(true)}>\n Edit Location\n </Button>\n)}\n</code></pre></p>"},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#12-cut-overlay-toggle","title":"12. Cut Overlay Toggle","text":"<p>Show/hide cut boundaries:</p> <p>Component: <pre><code><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</code></pre></p>"},{"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":"<ol> <li>Volunteer navigates to <code>/volunteer/canvass/:cutId</code></li> <li>Map loads centered on cut bounds</li> <li>Locations load within cut</li> <li>Volunteer clicks \"Start Session\" in drawer</li> <li>GPS tracking activates</li> <li>Session bar appears at bottom (above footer)</li> <li>Walking route calculates and displays</li> <li>User position marker appears and updates</li> </ol>"},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#recording-a-visit","title":"Recording a Visit","text":"<ol> <li>Volunteer walks to address</li> <li>Clicks marker or uses \"Next Door\" button</li> <li>VisitRecordingForm opens in bottom drawer</li> <li>Volunteer selects outcome from dropdown</li> <li>Volunteer adds notes (optional)</li> <li>Volunteer checks \"Follow-up Required\" if needed</li> <li>Volunteer clicks \"Save Visit\"</li> <li>API creates CanvassVisit record</li> <li>Marker updates to color-coded outcome</li> <li>Drawer closes</li> <li>Walking route recalculates</li> </ol>"},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#adding-a-missing-location","title":"Adding a Missing Location","text":"<ol> <li>Volunteer encounters unlisted address</li> <li>Opens menu drawer</li> <li>Clicks \"Add Location\"</li> <li>Crosshair appears at map center</li> <li>Volunteer pans map to position crosshair over address</li> <li>Clicks \"Tap Here to Add\"</li> <li>AddLocationDrawer opens</li> <li>Volunteer enters address</li> <li>Volunteer clicks \"Confirm\"</li> <li>API creates Location record</li> <li>New marker appears on map</li> <li>Volunteer can immediately record visit</li> </ol>"},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#finding-next-door","title":"Finding Next Door","text":"<ol> <li>Volunteer finishes current visit</li> <li>Clicks \"Next Door\" button (right side)</li> <li>Algorithm finds nearest unvisited location</li> <li>Map animates (flyTo) to location</li> <li>VisitRecordingForm opens automatically</li> <li>Volunteer records visit</li> <li>Repeats process</li> </ol>"},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#ending-session","title":"Ending Session","text":"<ol> <li>Volunteer clicks \"End Session\" in drawer</li> <li>Confirmation modal appears</li> <li>Modal shows session stats (duration, visits, distance)</li> <li>Volunteer clicks \"End Session\" confirm button</li> <li>GPS tracking stops</li> <li>Session marked as ENDED in database</li> <li>Session bar disappears</li> <li>Volunteer can start new session or navigate away</li> </ol>"},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#component-structure","title":"Component Structure","text":"<pre><code>import React, { useState, useEffect, useCallback } from 'react';\nimport { useParams } from 'react-router-dom';\nimport { MapContainer, TileLayer, useMap } from 'react-leaflet';\nimport { Button, message, Modal } from 'antd';\nimport {\n AimOutlined,\n PlusOutlined,\n ArrowRightOutlined,\n FullscreenOutlined\n} from '@ant-design/icons';\nimport GPSTracker from '../../components/canvass/GPSTracker';\nimport CanvassMarkerGroup from '../../components/canvass/CanvassMarkerGroup';\nimport WalkingRouteLine from '../../components/canvass/WalkingRouteLine';\nimport VisitRecordingForm from '../../components/canvass/VisitRecordingForm';\nimport AddLocationDrawer from '../../components/canvass/AddLocationDrawer';\nimport VolunteerMapDrawer from '../../components/canvass/VolunteerMapDrawer';\nimport VolunteerFooterNav from '../../components/canvass/VolunteerFooterNav';\nimport VolunteerSessionBar from '../../components/canvass/VolunteerSessionBar';\nimport { useCanvassStore } from '../../stores/canvass.store';\nimport { api } from '../../lib/api';\nimport 'leaflet/dist/leaflet.css';\n\nconst VolunteerMapPage: React.FC = () => {\n const { cutId } = useParams<{ cutId: string }>();\n const [map, setMap] = useState<L.Map | null>(null);\n\n // Canvass store\n const {\n activeSession,\n locations,\n visits,\n walkingRoute,\n userPosition,\n setActiveSession,\n addVisit,\n updateLocation,\n setUserPosition\n } = useCanvassStore();\n\n // UI state\n const [recordingDrawerVisible, setRecordingDrawerVisible] = useState(false);\n const [selectedLocation, setSelectedLocation] = useState<Location | null>(null);\n const [addLocationMode, setAddLocationMode] = useState(false);\n const [drawerVisible, setDrawerVisible] = useState(false);\n const [routeVisible, setRouteVisible] = useState(true);\n const [followMode, setFollowMode] = useState(true);\n\n // Fetch locations in cut\n useEffect(() => {\n const fetchLocations = async () => {\n try {\n const response = await api.get(`/api/map/canvass/locations/${cutId}`);\n // Store in Zustand\n } catch (error) {\n message.error('Failed to load locations');\n }\n };\n\n fetchLocations();\n }, [cutId]);\n\n return (\n <div style={{ height: '100vh', width: '100vw', position: 'relative' }}>\n <MapContainer\n center={[45.5017, -73.5673]}\n zoom={16}\n zoomControl={false}\n style={{ height: '100%', width: '100%' }}\n whenCreated={setMap}\n >\n <TileLayer\n url=\"https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png\"\n attribution='OSM'\n />\n\n <GPSTracker\n enabled={!!activeSession}\n onPositionUpdate={handlePositionUpdate}\n />\n\n <CanvassMarkerGroup\n locations={locations}\n visits={visits}\n onMarkerClick={handleMarkerClick}\n />\n\n {routeVisible && walkingRoute && (\n <WalkingRouteLine\n route={walkingRoute}\n userPosition={userPosition}\n />\n )}\n </MapContainer>\n\n <VolunteerMapDrawer\n visible={drawerVisible}\n onClose={() => setDrawerVisible(false)}\n onStartSession={handleStartSession}\n onEndSession={() => setEndModalVisible(true)}\n onAddLocation={() => setAddLocationMode(true)}\n session={activeSession}\n stats={sessionStats}\n />\n\n <VisitRecordingForm\n location={selectedLocation}\n sessionId={activeSession?.id}\n visible={recordingDrawerVisible}\n onClose={() => setRecordingDrawerVisible(false)}\n onSubmit={handleVisitSubmit}\n />\n\n <AddLocationDrawer\n visible={addLocationMode}\n position={map?.getCenter()}\n onConfirm={handleAddLocation}\n onCancel={() => setAddLocationMode(false)}\n />\n\n {activeSession && (\n <VolunteerSessionBar\n session={activeSession}\n onPause={handlePause}\n onEnd={() => setEndModalVisible(true)}\n />\n )}\n\n <VolunteerFooterNav activeKey=\"canvass\" />\n\n {/* Floating controls */}\n <div style={{ position: 'absolute', right: 16, top: 16, zIndex: 1000 }}>\n <Button\n icon={<AimOutlined />}\n onClick={handleGeolocate}\n size=\"large\"\n style={{ display: 'block', marginBottom: 8 }}\n />\n <Button\n icon={<ArrowRightOutlined />}\n onClick={handleNextDoor}\n size=\"large\"\n />\n </div>\n </div>\n );\n};\n\nexport default VolunteerMapPage;\n</code></pre>"},{"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":"<pre><code>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</code></pre>"},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#component-state","title":"Component State","text":"<pre><code>// 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</code></pre>"},{"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":"<pre><code>GET /api/map/canvass/locations/:cutId\nAuthorization: Bearer {token}\n</code></pre> <p>Response: <pre><code>[\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</code></pre></p>"},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#2-start-session","title":"2. Start Session","text":"<pre><code>POST /api/map/canvass/sessions/start\nAuthorization: Bearer {token}\nContent-Type: application/json\n\n{\n \"cutId\": \"cm1cut123\"\n}\n</code></pre> <p>Response: <pre><code>{\n \"sessionId\": \"cm2session456\",\n \"startTime\": \"2025-02-12T10:00:00.000Z\",\n \"status\": \"ACTIVE\"\n}\n</code></pre></p>"},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#3-record-visit","title":"3. Record Visit","text":"<pre><code>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</code></pre> <p>Response: <pre><code>{\n \"visitId\": \"cm3visit789\",\n \"createdAt\": \"2025-02-12T10:15:00.000Z\"\n}\n</code></pre></p>"},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#4-track-gps-position","title":"4. Track GPS Position","text":"<pre><code>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</code></pre>"},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#5-add-location","title":"5. Add Location","text":"<pre><code>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</code></pre>"},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#6-end-session","title":"6. End Session","text":"<pre><code>POST /api/map/canvass/sessions/:sessionId/end\nAuthorization: Bearer {token}\n</code></pre> <p>Response: <pre><code>{\n \"sessionId\": \"cm2session456\",\n \"endTime\": \"2025-02-12T12:00:00.000Z\",\n \"duration\": 7200,\n \"visitCount\": 23,\n \"distance\": 2834\n}\n</code></pre></p>"},{"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":"<pre><code>// 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</code></pre>"},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#walking-route-calculation","title":"Walking Route Calculation","text":"<pre><code>// 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</code></pre>"},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#canvass-marker-group","title":"Canvass Marker Group","text":"<pre><code>// 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</code></pre>"},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#performance-considerations","title":"Performance Considerations","text":"<ol> <li>Zustand Store: Global state prevents prop drilling</li> <li>Debounced GPS: Position tracked every 5 seconds (not every update)</li> <li>Route Recalc: Only recalculates when visits added</li> <li>Marker Clustering: Reduces DOM nodes on dense maps</li> <li>Lazy Drawers: Components mount only when opened</li> </ol>"},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#responsive-design","title":"Responsive Design","text":"<ul> <li>Mobile-First: Designed for phones (primary use case)</li> <li>Touch Gestures: Native Leaflet touch support</li> <li>Bottom Drawers: Accessible with thumb</li> <li>Large Touch Targets: All buttons 44px+ minimum</li> <li>Portrait Orientation: Optimized for vertical screens</li> </ul>"},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#accessibility","title":"Accessibility","text":"<ul> <li>GPS Feedback: Audible alerts for position updates (optional)</li> <li>High Contrast: CARTO Dark mode for low light</li> <li>Large Text: All UI text 14px minimum</li> <li>Voice Input: Notes field supports speech-to-text (browser)</li> </ul>"},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#issue-gps-not-working","title":"Issue: GPS Not Working","text":"<p>Causes: 1. HTTPS required (geolocation API restriction) 2. User denied permission 3. GPS unavailable (indoors, bad signal)</p> <p>Solutions: <pre><code>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</code></pre></p>"},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#issue-route-not-displaying","title":"Issue: Route Not Displaying","text":"<p>Causes: 1. No unvisited locations 2. Route calculation error 3. Route toggle off</p> <p>Solutions: <pre><code>// 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</code></pre></p>"},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#issue-session-not-ending","title":"Issue: Session Not Ending","text":"<p>Causes: 1. API timeout 2. Pending GPS uploads 3. Network disconnection</p> <p>Solutions: <pre><code>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</code></pre></p>"},{"location":"v2/frontend/pages/volunteer/volunteer-map-page/#related-documentation","title":"Related Documentation","text":"<ul> <li>Canvass System Architecture</li> <li>GPS Tracking</li> <li>Walking Route Algorithm</li> <li>Canvass Dashboard</li> <li>My Activity Page</li> <li>My Routes Page</li> </ul>"},{"location":"v2/frontend/pages/volunteer/volunteer-shifts-page/","title":"Volunteer Shifts Page","text":""},{"location":"v2/frontend/pages/volunteer/volunteer-shifts-page/#overview","title":"Overview","text":"<p>File Path: <code>admin/src/pages/volunteer/VolunteerShiftsPage.tsx</code> (312 lines)</p> <p>Route: <code>/volunteer/assignments</code></p> <p>Role Requirements: Authenticated users (USER or TEMP role)</p> <p>Purpose: Volunteer-facing shift management showing upcoming shifts and personal signups with signup/cancel functionality.</p> <p>Key Features:</p> <ul> <li>Segmented tabs: \"Upcoming Shifts\" / \"My Signups\"</li> <li>Responsive shift cards grid (xs=1, sm=2 columns)</li> <li>Progress bar showing volunteer capacity</li> <li>Signup confirmation modal</li> <li>Cancel signup modal (danger button)</li> <li>\"Signed Up\" badge + cancel link on signed-up shifts</li> <li>Empty states for no shifts/signups</li> <li>Dark theme (VolunteerLayout)</li> </ul> <p>Layout: Uses <code>VolunteerLayout</code> with top navigation</p>"},{"location":"v2/frontend/pages/volunteer/volunteer-shifts-page/#features","title":"Features","text":""},{"location":"v2/frontend/pages/volunteer/volunteer-shifts-page/#1-segmented-tabs","title":"1. Segmented Tabs","text":"<pre><code><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</code></pre>"},{"location":"v2/frontend/pages/volunteer/volunteer-shifts-page/#2-shift-cards","title":"2. Shift Cards","text":"<p>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</p> <p>My Signups Tab: - Shows only user's signups - \"Cancel Signup\" button (danger) - Shift details emphasized</p>"},{"location":"v2/frontend/pages/volunteer/volunteer-shifts-page/#3-capacity-progress-bar","title":"3. Capacity Progress Bar","text":"<pre><code>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</code></pre>"},{"location":"v2/frontend/pages/volunteer/volunteer-shifts-page/#4-signup-confirmation-modal","title":"4. Signup Confirmation Modal","text":"<pre><code><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</code></pre>"},{"location":"v2/frontend/pages/volunteer/volunteer-shifts-page/#5-cancel-signup-modal","title":"5. Cancel Signup Modal","text":"<pre><code><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</code></pre>"},{"location":"v2/frontend/pages/volunteer/volunteer-shifts-page/#state-management","title":"State Management","text":"<pre><code>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</code></pre>"},{"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":"<pre><code>GET /api/map/shifts/upcoming\nAuthorization: Bearer {token}\n</code></pre> <p>Response: <pre><code>[\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</code></pre></p>"},{"location":"v2/frontend/pages/volunteer/volunteer-shifts-page/#2-get-my-signups","title":"2. Get My Signups","text":"<pre><code>GET /api/map/shifts/my-signups\nAuthorization: Bearer {token}\n</code></pre>"},{"location":"v2/frontend/pages/volunteer/volunteer-shifts-page/#3-sign-up","title":"3. Sign Up","text":"<pre><code>POST /api/map/shifts/:id/signup\nAuthorization: Bearer {token}\n</code></pre> <p>Response: <pre><code>{\n \"success\": true,\n \"signupId\": \"cm3signup456\",\n \"message\": \"Successfully signed up for shift\"\n}\n</code></pre></p>"},{"location":"v2/frontend/pages/volunteer/volunteer-shifts-page/#4-cancel-signup","title":"4. Cancel Signup","text":"<pre><code>DELETE /api/map/shifts/:id/cancel\nAuthorization: Bearer {token}\n</code></pre>"},{"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":"<pre><code><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</code></pre>"},{"location":"v2/frontend/pages/volunteer/volunteer-shifts-page/#my-signups-tab","title":"My Signups Tab","text":"<pre><code>{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</code></pre>"},{"location":"v2/frontend/pages/volunteer/volunteer-shifts-page/#performance-considerations","title":"Performance Considerations","text":"<ol> <li>Parallel Fetches: Upcoming shifts and signups fetched simultaneously</li> <li>Optimistic Updates: Signup/cancel updates UI immediately</li> <li>Tab State: No refetch when switching tabs (cached)</li> <li>Debounced Modals: Prevent double-submission with loading state</li> </ol>"},{"location":"v2/frontend/pages/volunteer/volunteer-shifts-page/#responsive-design","title":"Responsive Design","text":"<ul> <li>Mobile: Single column cards (xs=24)</li> <li>Tablet: Two column grid (sm=12)</li> <li>Desktop: Two column grid maintained (not 3+ for readability)</li> <li>Segmented Tabs: Full-width on mobile, auto-width on desktop</li> </ul>"},{"location":"v2/frontend/pages/volunteer/volunteer-shifts-page/#accessibility","title":"Accessibility","text":"<ul> <li>Tab Navigation: Segmented component keyboard accessible</li> <li>Button Labels: Clear action labels (\"Sign Up\", \"Cancel Signup\")</li> <li>Modal Focus: Auto-focus on OK button</li> <li>Screen Reader: Empty states announce \"No shifts available\"</li> </ul>"},{"location":"v2/frontend/pages/volunteer/volunteer-shifts-page/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/frontend/pages/volunteer/volunteer-shifts-page/#issue-signed-up-badge-not-showing","title":"Issue: Signed Up Badge Not Showing","text":"<p>Cause: <code>isSignedUp</code> field not populated by API</p> <p>Solution: <pre><code>// 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</code></pre></p>"},{"location":"v2/frontend/pages/volunteer/volunteer-shifts-page/#issue-cancel-not-refreshing-list","title":"Issue: Cancel Not Refreshing List","text":"<p>Solution: <pre><code>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</code></pre></p>"},{"location":"v2/frontend/pages/volunteer/volunteer-shifts-page/#related-documentation","title":"Related Documentation","text":"<ul> <li>Public Shifts Page</li> <li>Admin Shifts Page</li> <li>Volunteer Map Page</li> <li>VolunteerLayout</li> </ul>"},{"location":"v2/getting-started/","title":"Getting Started with Changemaker Lite V2","text":"<p>Welcome to Changemaker Lite V2! This guide will help you get up and running quickly with your self-hosted political campaign platform.</p>"},{"location":"v2/getting-started/#what-is-changemaker-lite-v2","title":"What is Changemaker Lite V2?","text":"<p>Changemaker Lite V2 is a complete rebuild of the platform with a modern TypeScript stack, offering:</p> <ul> <li>Email Advocacy Campaigns: Target elected representatives with automated email campaigns</li> <li>Geographic Mapping: Manage locations, cuts (territories), and canvassing operations</li> <li>Volunteer Management: Schedule shifts, track canvassing visits with GPS</li> <li>Landing Page Builder: Create public-facing pages with GrapesJS editor</li> <li>Newsletter Integration: Sync with Listmonk for email marketing</li> <li>Media Library: Manage video content with public gallery</li> <li>Comprehensive Monitoring: Prometheus + Grafana observability stack</li> </ul>"},{"location":"v2/getting-started/#prerequisites","title":"Prerequisites","text":"<p>Before you begin, ensure you have:</p> <ul> <li>Linux server or macOS with Docker installed</li> <li>Docker 20.10+ and Docker Compose 2.0+</li> <li>4GB RAM minimum (8GB recommended for monitoring stack)</li> <li>20GB disk space (more for media uploads)</li> <li>Root or sudo access</li> <li>Basic command line familiarity</li> </ul>"},{"location":"v2/getting-started/#optional-but-recommended","title":"Optional but Recommended","text":"<ul> <li>Domain name with DNS control (for production deployment)</li> <li>SMTP server for email sending (or use MailHog for testing)</li> <li>S3-compatible storage for backups (Backblaze B2, AWS S3, etc.)</li> </ul>"},{"location":"v2/getting-started/#quick-start-options","title":"Quick Start Options","text":"<p>Choose your path based on your needs:</p>"},{"location":"v2/getting-started/#option-1-quick-start-5-minutes","title":"Option 1: Quick Start (5 Minutes)","text":"<p>Get the platform running locally for evaluation and testing.</p> <p>\u2192 Quick Start Guide</p>"},{"location":"v2/getting-started/#option-2-full-installation-30-minutes","title":"Option 2: Full Installation (30 Minutes)","text":"<p>Set up for production with custom configuration, monitoring, and backups.</p> <p>\u2192 Full Installation Guide</p>"},{"location":"v2/getting-started/#option-3-local-development-45-minutes","title":"Option 3: Local Development (45 Minutes)","text":"<p>Set up a complete development environment with hot reload and debugging.</p> <p>\u2192 Development Setup</p>"},{"location":"v2/getting-started/#architecture-overview","title":"Architecture Overview","text":"<p>Changemaker Lite V2 uses a microservices architecture:</p> <pre><code>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</code></pre> <p>Key Components:</p> <ul> <li>Nginx: Routes requests to appropriate services based on subdomain</li> <li>Admin GUI: React application (Vite + Ant Design) for platform management</li> <li>Express API: Main backend with 14 feature modules (Prisma ORM)</li> <li>Fastify API: Media library microservice (Drizzle ORM)</li> <li>PostgreSQL: Primary database (Prisma + Drizzle schemas)</li> <li>Redis: Caching, rate limiting, job queue backend</li> </ul> <p>Learn more about the architecture \u2192</p>"},{"location":"v2/getting-started/#whats-next","title":"What's Next?","text":"<p>After installation, you'll want to:</p> <ol> <li>First Login - Access the admin interface and change default credentials</li> <li>Environment Configuration - Customize your .env file for your needs</li> <li>Docker Management - Learn to start, stop, and manage services</li> <li>Admin Guide - Platform administration workflows</li> </ol>"},{"location":"v2/getting-started/#common-installation-issues","title":"Common Installation Issues","text":"<p>If you encounter problems during setup, check our troubleshooting guides:</p> <ul> <li>Docker Issues - Port conflicts, volume permissions</li> <li>Database Issues - Connection errors, migrations</li> <li>Common Errors - General troubleshooting</li> </ul>"},{"location":"v2/getting-started/#getting-help","title":"Getting Help","text":"<ul> <li>Documentation Search: Use the search bar above to find specific topics</li> <li>FAQ: Check the Frequently Asked Questions</li> <li>Issue Tracker: Report bugs or request features on GitHub</li> </ul>"},{"location":"v2/getting-started/#feature-highlights","title":"Feature Highlights","text":""},{"location":"v2/getting-started/#influence-module","title":"Influence Module","text":"<p>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</p>"},{"location":"v2/getting-started/#map-module","title":"Map Module","text":"<p>Coordinate field operations with: - Multi-provider geocoding (6 services) - Territory management (cuts) - GPS-tracked canvassing - Printable walk sheets with QR codes</p>"},{"location":"v2/getting-started/#landing-pages","title":"Landing Pages","text":"<p>Build custom public pages with: - GrapesJS drag-and-drop editor - MkDocs export for static sites - Mobile-responsive templates</p>"},{"location":"v2/getting-started/#monitoring","title":"Monitoring","text":"<p>Keep your platform healthy with: - Real-time metrics dashboards - Custom alerts - Service health monitoring - Data quality tracking</p> <p>Explore all features \u2192</p> <p>Ready to get started? Choose your installation path above!</p>"},{"location":"v2/getting-started/quick-start/","title":"Quick Start Guide","text":"<p>Get Changemaker Lite V2 running in 5 minutes with this streamlined guide.</p> <p>For Evaluation Only</p> <p>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.</p>"},{"location":"v2/getting-started/quick-start/#step-1-clone-the-repository","title":"Step 1: Clone the Repository","text":"<pre><code>git clone <repo-url> changemaker.lite\ncd changemaker.lite\ngit checkout v2\n</code></pre> <p>Tip</p> <p>If you're evaluating locally, you can skip the domain configuration and use <code>localhost</code> URLs.</p>"},{"location":"v2/getting-started/quick-start/#step-2-create-environment-file","title":"Step 2: Create Environment File","text":"<pre><code>cp .env.example .env\n</code></pre> <p>Edit <code>.env</code> and set the minimum required variables:</p> <pre><code># 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</code></pre> <p>Generate Secure Secrets</p> <pre><code>echo \"JWT_ACCESS_SECRET=$(openssl rand -hex 32)\"\necho \"JWT_REFRESH_SECRET=$(openssl rand -hex 32)\"\necho \"ENCRYPTION_KEY=$(openssl rand -hex 32)\"\n</code></pre>"},{"location":"v2/getting-started/quick-start/#step-3-start-core-services","title":"Step 3: Start Core Services","text":"<pre><code># 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</code></pre>"},{"location":"v2/getting-started/quick-start/#step-4-run-database-migrations","title":"Step 4: Run Database Migrations","text":"<pre><code># 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</code></pre> <p>This creates: - Admin user: <code>admin@example.com</code> / <code>Admin123!</code> - Site settings: Default configuration - Page blocks: Landing page components</p>"},{"location":"v2/getting-started/quick-start/#step-5-access-the-platform","title":"Step 5: Access the Platform","text":"<p>Open your browser and navigate to:</p> <ul> <li>Admin Interface: http://localhost:3000</li> <li>API: http://localhost:4000</li> <li>API Health Check: http://localhost:4000/health</li> </ul> <p>Login with: - Email: <code>admin@example.com</code> - Password: <code>Admin123!</code></p> <p>Change Default Credentials</p> <p>Immediately change the default admin password:</p> <ol> <li>Navigate to Settings in the sidebar</li> <li>Click your profile</li> <li>Change password to something secure (12+ chars, mixed case, numbers)</li> </ol>"},{"location":"v2/getting-started/quick-start/#step-6-verify-installation","title":"Step 6: Verify Installation","text":"<p>Check that all services are running:</p> <pre><code>docker compose ps\n</code></pre> <p>You should see: - <code>v2-postgres</code> - Database (port 5433) - <code>redis-changemaker</code> - Cache (port 6379) - <code>api</code> - Express API (port 4000) - <code>admin</code> - React admin (port 3000) - <code>nginx</code> - Reverse proxy (port 80)</p> <p>Test the API:</p> <pre><code>curl http://localhost:4000/health\n</code></pre> <p>Expected response: <pre><code>{\n \"status\": \"healthy\",\n \"timestamp\": \"2026-02-11T18:00:00.000Z\"\n}\n</code></pre></p>"},{"location":"v2/getting-started/quick-start/#whats-next","title":"What's Next?","text":"<p>Now that you have Changemaker Lite running:</p> <ol> <li>First Login - Tour the admin interface</li> <li>Environment Configuration - Customize your setup</li> <li>Create Your First Campaign - Run an advocacy campaign</li> <li>Import Locations - Set up your map</li> </ol>"},{"location":"v2/getting-started/quick-start/#optional-start-additional-services","title":"Optional: Start Additional Services","text":""},{"location":"v2/getting-started/quick-start/#email-testing-mailhog","title":"Email Testing (MailHog)","text":"<p>Capture emails in development without sending real messages:</p> <pre><code>docker compose up -d mailhog\n</code></pre> <p>Access at: http://localhost:8025</p>"},{"location":"v2/getting-started/quick-start/#data-browser-nocodb","title":"Data Browser (NocoDB)","text":"<p>Read-only database browser:</p> <pre><code>docker compose up -d nocodb-v2\n</code></pre> <p>Access at: http://localhost:8091</p>"},{"location":"v2/getting-started/quick-start/#newsletter-platform-listmonk","title":"Newsletter Platform (Listmonk)","text":"<p>Email marketing integration:</p> <pre><code>docker compose up -d listmonk-postgres listmonk listmonk-init\n</code></pre> <p>Access at: http://localhost:9001</p>"},{"location":"v2/getting-started/quick-start/#monitoring-stack","title":"Monitoring Stack","text":"<p>Prometheus + Grafana + Alertmanager:</p> <pre><code>docker compose --profile monitoring up -d\n</code></pre> <p>Access: - Grafana: http://localhost:3001 (admin/admin) - Prometheus: http://localhost:9090 - Alertmanager: http://localhost:9093</p>"},{"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":"<p>If you see errors like <code>port is already allocated</code>:</p> <pre><code># Check what's using the port\nsudo lsof -i :3000\n\n# Stop the conflicting service or change ports in .env\n</code></pre>"},{"location":"v2/getting-started/quick-start/#database-connection-failed","title":"Database Connection Failed","text":"<pre><code># 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</code></pre>"},{"location":"v2/getting-started/quick-start/#api-wont-start","title":"API Won't Start","text":"<pre><code># View API logs\ndocker compose logs api\n\n# Common fix: rebuild the container\ndocker compose build api\ndocker compose up -d api\n</code></pre> <p>See full troubleshooting guide \u2192</p>"},{"location":"v2/getting-started/quick-start/#stopping-services","title":"Stopping Services","text":"<pre><code># Stop all services\ndocker compose down\n\n# Stop and remove volumes (WARNING: deletes all data)\ndocker compose down -v\n</code></pre>"},{"location":"v2/getting-started/quick-start/#next-steps-for-production","title":"Next Steps for Production","text":"<p>This quick start is for evaluation only. Before production deployment:</p> <ol> <li>Full Installation Guide - Production-ready setup</li> <li>Security Checklist - Harden your installation</li> <li>Backup Strategy - Protect your data</li> <li>Tunneling Setup - Public access via Pangolin</li> <li>Monitoring Configuration - Production observability</li> </ol> <p>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.</p>"},{"location":"v2/migration/","title":"Migration Guide: V1 to V2 Overview","text":"<p>This comprehensive guide covers the complete migration process from Changemaker Lite V1 to V2, including architectural changes, data migration, and rollback procedures.</p>"},{"location":"v2/migration/#overview","title":"Overview","text":"<p>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.</p>"},{"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":"<ol> <li>Unified Codebase: Single API codebase instead of two separate applications</li> <li>Type Safety: Full TypeScript coverage with Prisma type generation</li> <li>Modern Stack: Latest React, Vite build tooling, Ant Design components</li> <li>Better Performance: Direct database access via Prisma vs REST API abstraction</li> <li>Improved Security: JWT refresh token rotation, RBAC, comprehensive audit trail</li> <li>Scalability: Separation of concerns (dual API architecture for media)</li> <li>Developer Experience: Hot reload, better tooling, comprehensive documentation</li> </ol>"},{"location":"v2/migration/#feature-enhancements","title":"Feature Enhancements","text":"<ol> <li>New Features:</li> <li>Landing page builder with GrapesJS</li> <li>Email template system with versioning</li> <li>Media library with video uploads and reactions</li> <li>Volunteer canvassing system with GPS tracking</li> <li>Data quality dashboard for geocoding</li> <li>Comprehensive monitoring (Prometheus + Grafana)</li> <li>NAR 2025 electoral data import</li> <li> <p>Pangolin tunnel integration</p> </li> <li> <p>Enhanced Existing Features:</p> </li> <li>Response wall with upvoting and moderation</li> <li>Multi-provider geocoding (6 providers)</li> <li>Advanced shift management with cut assignments</li> <li>Printable walk sheets with QR codes</li> <li> <p>Listmonk newsletter sync</p> </li> <li> <p>Improved Admin Experience:</p> </li> <li>Modern React UI with consistent design</li> <li>Real-time updates with optimistic UI</li> <li>Advanced filtering and search</li> <li>Bulk operations</li> <li>Responsive mobile support</li> </ol>"},{"location":"v2/migration/#migration-timeline","title":"Migration Timeline","text":""},{"location":"v2/migration/#planned-phases-from-v2_planmd","title":"Planned Phases (from V2_PLAN.md)","text":"<ul> <li>Phase 1-14: \u2705 COMPLETE (Foundation through Monitoring)</li> <li>Phase 15: \ud83d\udea7 Testing + Polish (current)</li> </ul>"},{"location":"v2/migration/#actual-development-timeline","title":"Actual Development Timeline","text":"<ul> <li>2025-01: V2 rebuild initiated (clean-room approach)</li> <li>2025-02: Security audit completed, NAR import, media upload</li> <li>2026-02: Phase 14 complete, ready for production migration</li> </ul>"},{"location":"v2/migration/#migration-duration-estimate","title":"Migration Duration Estimate","text":"Migration Step Duration Downtime Required V1 data export 1-2 hours No Data transformation 2-4 hours No V2 database setup 30 minutes No V2 data import 1-3 hours No Testing & validation 2-4 hours No DNS/service switchover 15 minutes Yes Post-migration verification 1 hour No Total 8-15 hours 15 minutes <p>Minimize Downtime</p> <p>Perform all data export, transformation, and testing on a separate V2 staging environment. Only switch production traffic after full validation.</p>"},{"location":"v2/migration/#risk-assessment","title":"Risk Assessment","text":""},{"location":"v2/migration/#high-risk-areas","title":"High Risk Areas","text":"<ol> <li>Data Loss</li> <li>Risk: Campaign data, locations, or user accounts lost during migration</li> <li>Mitigation: Full V1 backup before migration, validation checksums, rollback plan</li> <li> <p>Impact: High (business-critical data)</p> </li> <li> <p>Authentication Disruption</p> </li> <li>Risk: Users unable to login after migration (password hash incompatibility)</li> <li>Mitigation: Test password migration with sample users, password reset flow ready</li> <li> <p>Impact: High (blocks all access)</p> </li> <li> <p>Email Delivery Failure</p> </li> <li>Risk: Campaign emails stop sending after migration</li> <li>Mitigation: Test SMTP configuration, BullMQ queue verification, MailHog testing</li> <li>Impact: High (core feature)</li> </ol>"},{"location":"v2/migration/#medium-risk-areas","title":"Medium Risk Areas","text":"<ol> <li>Representative Data</li> <li>Risk: Cached representative data doesn't migrate correctly</li> <li>Mitigation: Cache can be rebuilt from Represent API, non-critical</li> <li> <p>Impact: Medium (cacheable data)</p> </li> <li> <p>Location Geocoding</p> </li> <li>Risk: Geocoded coordinates lost or corrupted</li> <li>Mitigation: V2 multi-provider geocoding can re-geocode, bulk geocode endpoint</li> <li> <p>Impact: Medium (can be re-geocoded)</p> </li> <li> <p>Shift Signups</p> </li> <li>Risk: Volunteer shift assignments lost</li> <li>Mitigation: Export signups separately, manual verification, confirmation emails</li> <li>Impact: Medium (time-sensitive data)</li> </ol>"},{"location":"v2/migration/#low-risk-areas","title":"Low Risk Areas","text":"<ol> <li>Response Wall Data</li> <li>Risk: Public responses or upvotes lost</li> <li>Mitigation: CSV export, manual re-entry if needed</li> <li> <p>Impact: Low (public-facing only)</p> </li> <li> <p>Custom Settings</p> </li> <li>Risk: V1 settings don't map to V2 schema</li> <li>Mitigation: Manual reconfiguration in V2 SettingsPage</li> <li>Impact: Low (quick to reconfigure)</li> </ol>"},{"location":"v2/migration/#rollback-plan","title":"Rollback Plan","text":""},{"location":"v2/migration/#if-migration-fails","title":"If Migration Fails","text":"<ol> <li> <p>Immediate Actions (within 15 minutes): <pre><code># 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</code></pre></p> </li> <li> <p>Data Restoration (if V2 data was modified): <pre><code># 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</code></pre></p> </li> <li> <p>Verification:</p> </li> <li>Test V1 login</li> <li>Verify campaign data visible</li> <li>Check location map loads</li> <li>Send test campaign email</li> <li>Verify response wall displays</li> </ol>"},{"location":"v2/migration/#rollback-window","title":"Rollback Window","text":"<ul> <li>First 24 hours: Simple rollback (V1 backup unchanged)</li> <li>After 24 hours: Complex rollback (may need to merge V2 changes back to V1)</li> <li>After 1 week: Rollback not recommended (significant V2 data divergence)</li> </ul> <p>Rollback Deadline</p> <p>Plan your migration with a clear rollback deadline. After this window, V2 becomes the source of truth.</p>"},{"location":"v2/migration/#support-resources","title":"Support Resources","text":""},{"location":"v2/migration/#documentation","title":"Documentation","text":"<ul> <li>Migration Docs:</li> <li>Breaking Changes - Detailed V1\u2192V2 differences</li> <li>Data Migration - Step-by-step data transfer</li> <li>API Changes - Endpoint mapping table</li> <li> <p>Feature Parity - Feature comparison matrix</p> </li> <li> <p>V2 Docs:</p> </li> <li>Getting Started - V2 installation</li> <li>Architecture - System design</li> <li>Deployment - Production setup</li> </ul>"},{"location":"v2/migration/#community-support","title":"Community & Support","text":"<ul> <li>GitHub Issues: Report bugs or migration problems</li> <li>Discussions: Ask questions, share migration experiences</li> <li>Email: support@cmlite.org for direct assistance</li> </ul>"},{"location":"v2/migration/#professional-services","title":"Professional Services","text":"<p>For organizations requiring: - Custom data migration scripts - Zero-downtime migration - Training for administrators - Priority support during migration</p> <p>Contact: enterprise@cmlite.org</p>"},{"location":"v2/migration/#prerequisites","title":"Prerequisites","text":"<p>Before beginning migration, ensure you have:</p>"},{"location":"v2/migration/#v1-environment","title":"V1 Environment","text":"<ul> <li> V1 backup completed (database + uploads)</li> <li> V1 environment variables documented (<code>.env</code> file)</li> <li> V1 access credentials (NocoDB admin, database passwords)</li> <li> V1 running and healthy (all services operational)</li> <li> V1 data export tested (able to export NocoDB tables)</li> </ul>"},{"location":"v2/migration/#v2-environment","title":"V2 Environment","text":"<ul> <li> V2 repository cloned (<code>git checkout v2</code>)</li> <li> Docker and Docker Compose installed (20.10+, 2.0+)</li> <li> PostgreSQL 16 compatible (for V2 database)</li> <li> 4GB+ RAM available (8GB recommended)</li> <li> 20GB+ disk space (for database + uploads)</li> </ul>"},{"location":"v2/migration/#migration-planning","title":"Migration Planning","text":"<ul> <li> Downtime window scheduled (notify users)</li> <li> Rollback plan reviewed (tested on staging)</li> <li> Team assigned (minimum 2 people recommended)</li> <li> Backup storage ready (S3 bucket or local storage)</li> <li> Testing checklist prepared (critical workflows to verify)</li> </ul>"},{"location":"v2/migration/#migration-steps-overview","title":"Migration Steps Overview","text":"<p>This is a high-level overview. Detailed steps are in Data Migration.</p>"},{"location":"v2/migration/#phase-1-preparation-no-downtime","title":"Phase 1: Preparation (No Downtime)","text":"<ol> <li> <p>Export V1 Data <pre><code># 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</code></pre></p> </li> <li> <p>Set Up V2 Environment <pre><code>git checkout v2\ncp .env.example .env\n# Edit .env with V2 configuration\n</code></pre></p> </li> <li> <p>Start V2 Services (parallel to V1) <pre><code>docker compose up -d v2-postgres redis\ndocker compose exec api npx prisma migrate deploy\n</code></pre></p> </li> </ol>"},{"location":"v2/migration/#phase-2-data-transformation-no-downtime","title":"Phase 2: Data Transformation (No Downtime)","text":"<ol> <li> <p>Transform V1 Data for V2 <pre><code># Run transformation scripts\nnode scripts/transform-users.js\nnode scripts/transform-campaigns.js\nnode scripts/transform-locations.js\n</code></pre></p> </li> <li> <p>Import into V2 Database <pre><code># Import transformed data\ndocker compose exec api node scripts/import-data.js\n</code></pre></p> </li> <li> <p>Validate Data Integrity <pre><code># Compare record counts\ndocker compose exec api node scripts/validate-migration.js\n</code></pre></p> </li> </ol>"},{"location":"v2/migration/#phase-3-testing-no-downtime","title":"Phase 3: Testing (No Downtime)","text":"<ol> <li>Test V2 Functionality</li> <li>Login with test users (verify password migration)</li> <li>View campaigns, locations, shifts</li> <li>Submit test response</li> <li>Send test email</li> <li> <p>Check admin permissions</p> </li> <li> <p>Performance Testing</p> </li> <li>Load campaigns page (check query performance)</li> <li>Geocode sample addresses</li> <li>Test map rendering with all locations</li> <li>Verify Redis caching</li> </ol>"},{"location":"v2/migration/#phase-4-switchover-15-minutes-downtime","title":"Phase 4: Switchover (15 Minutes Downtime)","text":"<ol> <li> <p>Enable Maintenance Mode (V1) <pre><code># Stop V1 services\ndocker compose -f docker-compose.v1.yml down\n</code></pre></p> </li> <li> <p>Start V2 Services <pre><code># Start all V2 services\ndocker compose up -d\n</code></pre></p> </li> <li> <p>Update DNS/Proxy</p> <ul> <li>Point <code>cmlite.org</code> to V2 nginx</li> <li>Update Pangolin tunnel endpoint</li> <li>Verify SSL certificates</li> </ul> </li> </ol>"},{"location":"v2/migration/#phase-5-verification-post-migration","title":"Phase 5: Verification (Post-Migration)","text":"<ol> <li> <p>Smoke Tests</p> <ul> <li>Admin login works</li> <li>Campaign list loads</li> <li>Location map renders</li> <li>Email sending functional</li> <li>Response wall displays</li> </ul> </li> <li> <p>Monitor for Issues <pre><code># Watch logs for errors\ndocker compose logs -f api admin\n\n# Check metrics\nopen http://localhost:3001 # Grafana\n</code></pre></p> </li> <li> <p>Announce Migration Complete</p> <ul> <li>Email all users with V2 login URL</li> <li>Update documentation links</li> <li>Monitor support channels</li> </ul> </li> </ol>"},{"location":"v2/migration/#post-migration-checklist","title":"Post-Migration Checklist","text":"<p>After successful migration, complete these tasks:</p>"},{"location":"v2/migration/#immediate-day-1","title":"Immediate (Day 1)","text":"<ul> <li> Verify all user accounts can login</li> <li> Test campaign email sending (real SMTP, not MailHog)</li> <li> Confirm location geocoding works</li> <li> Check shift signup flow (public)</li> <li> Verify response wall displays correctly</li> <li> Test admin CRUD operations (create campaign, location, shift)</li> <li> Monitor error logs for exceptions</li> <li> Verify Prometheus metrics collecting</li> </ul>"},{"location":"v2/migration/#first-week","title":"First Week","text":"<ul> <li> Review Grafana dashboards for anomalies</li> <li> Check BullMQ job queue (no stuck jobs)</li> <li> Verify geocoding cache hit rate</li> <li> Test all user roles (SUPER_ADMIN, MAP_ADMIN, etc.)</li> <li> Confirm Listmonk sync working (if enabled)</li> <li> Validate backup script runs successfully</li> <li> Review user feedback and support tickets</li> </ul>"},{"location":"v2/migration/#first-month","title":"First Month","text":"<ul> <li> Optimize slow queries (check Prometheus API duration metrics)</li> <li> Review disk usage (PostgreSQL, uploads, logs)</li> <li> Audit user permissions (remove temp accounts)</li> <li> Update documentation based on issues encountered</li> <li> Train administrators on new V2 features</li> <li> Plan rollout of new features (landing pages, canvassing)</li> <li> Schedule security audit</li> </ul>"},{"location":"v2/migration/#common-migration-scenarios","title":"Common Migration Scenarios","text":""},{"location":"v2/migration/#scenario-1-small-organization-1000-locations","title":"Scenario 1: Small Organization (< 1000 locations)","text":"<ul> <li>Migration Duration: 4-6 hours</li> <li>Downtime: 10 minutes</li> <li>Recommended Approach:</li> <li>Export V1 data Friday evening</li> <li>Transform and import over weekend</li> <li>Test Saturday/Sunday</li> <li>Switchover Monday morning</li> <li>Rollback window: 48 hours</li> </ul>"},{"location":"v2/migration/#scenario-2-medium-organization-1000-10000-locations","title":"Scenario 2: Medium Organization (1000-10000 locations)","text":"<ul> <li>Migration Duration: 8-12 hours</li> <li>Downtime: 15 minutes</li> <li>Recommended Approach:</li> <li>Set up V2 staging environment 1 week prior</li> <li>Perform test migration on staging</li> <li>Document issues and solutions</li> <li>Schedule production migration for low-traffic period</li> <li>Rollback window: 24 hours</li> </ul>"},{"location":"v2/migration/#scenario-3-large-organization-10000-locations","title":"Scenario 3: Large Organization (10000+ locations)","text":"<ul> <li>Migration Duration: 12-20 hours</li> <li>Downtime: 20-30 minutes</li> <li>Recommended Approach:</li> <li>Hire professional services (enterprise@cmlite.org)</li> <li>Perform multiple test migrations on staging</li> <li>Use incremental data sync (minimize final catchup)</li> <li>Blue-green deployment (parallel V1/V2 for 1 week)</li> <li>Rollback window: 1 week with data sync</li> </ul>"},{"location":"v2/migration/#scenario-4-active-campaign-during-migration","title":"Scenario 4: Active Campaign During Migration","text":"<p>Problem: Can't afford downtime during critical campaign period.</p> <p>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</p> <p>Active Campaign Warning</p> <p>Do NOT migrate during active campaign periods. Schedule migration between campaigns or during organizational downtime.</p>"},{"location":"v2/migration/#migration-validation-checklist","title":"Migration Validation Checklist","text":"<p>Use this checklist to verify successful migration:</p>"},{"location":"v2/migration/#data-integrity","title":"Data Integrity","text":"<ul> <li> User count matches: V1 users = V2 users (excluding duplicates)</li> <li> Campaign count matches: V1 campaigns = V2 campaigns</li> <li> Location count matches: V1 locations = V2 locations</li> <li> Shift count matches: V1 shifts = V2 shifts</li> <li> Response count matches: V1 responses = V2 responses</li> <li> Representative cache count: V1 reps = V2 reps (approximate, can refresh)</li> </ul>"},{"location":"v2/migration/#functional-testing","title":"Functional Testing","text":"<ul> <li> Login works: Test with 5 different user accounts</li> <li> Password authentication: All migrated passwords validate correctly</li> <li> Campaign email sends: Queue job, verify SMTP delivery</li> <li> Representative lookup: Postal code returns correct reps</li> <li> Location geocoding: Bulk geocode 10 addresses successfully</li> <li> Map rendering: All locations display on map</li> <li> Shift signup: Public user can sign up for shift</li> <li> Response submission: Can submit and view responses</li> <li> Admin CRUD: Create, edit, delete test records</li> </ul>"},{"location":"v2/migration/#performance-testing","title":"Performance Testing","text":"<ul> <li> Campaign list loads < 2 seconds: 100+ campaigns</li> <li> Location map loads < 3 seconds: 1000+ locations</li> <li> Search response time < 500ms: User, campaign, location search</li> <li> Geocoding batch < 30 seconds: 100 addresses</li> <li> Email queue processing: 10 emails/minute minimum</li> <li> No N+1 queries: Check Prisma logs for query count</li> </ul>"},{"location":"v2/migration/#security-testing","title":"Security Testing","text":"<ul> <li> JWT authentication works: Access + refresh token flow</li> <li> RBAC enforced: SUPER_ADMIN vs USER vs TEMP roles</li> <li> Rate limiting active: Auth endpoints limited to 10/min</li> <li> Password policy enforced: 12+ chars, complexity requirements</li> <li> Redis authenticated: Connection requires password</li> <li> Encryption key set: ENCRYPTION_KEY env var different from JWT secrets</li> </ul>"},{"location":"v2/migration/#troubleshooting-migration-issues","title":"Troubleshooting Migration Issues","text":"<p>Common problems and solutions:</p>"},{"location":"v2/migration/#issue-user-login-fails-after-migration","title":"Issue: User Login Fails After Migration","text":"<p>Symptoms: Users receive \"Invalid credentials\" error despite correct password.</p> <p>Causes: - Bcrypt hash corruption during export/import - Password field length truncation - Character encoding issues</p> <p>Solutions: <pre><code># 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</code></pre></p>"},{"location":"v2/migration/#issue-missing-data-after-import","title":"Issue: Missing Data After Import","text":"<p>Symptoms: User count, campaign count, or location count lower than V1.</p> <p>Causes: - Incomplete V1 export (pagination issues) - Transformation script errors (check logs) - Unique constraint violations (duplicates skipped)</p> <p>Solutions: <pre><code># 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</code></pre></p>"},{"location":"v2/migration/#issue-geocoding-data-lost","title":"Issue: Geocoding Data Lost","text":"<p>Symptoms: Locations missing latitude/longitude coordinates.</p> <p>Causes: - V1 geocoding provider different from V2 - Coordinates not exported from V1 - Transformation script didn't map geocoding fields</p> <p>Solutions: <pre><code># 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</code></pre></p>"},{"location":"v2/migration/#issue-campaign-emails-not-sending","title":"Issue: Campaign Emails Not Sending","text":"<p>Symptoms: BullMQ queue shows \"failed\" jobs.</p> <p>Causes: - SMTP configuration incorrect - EMAIL_TEST_MODE still enabled (sends to MailHog) - Nodemailer authentication failure</p> <p>Solutions: <pre><code># 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</code></pre></p>"},{"location":"v2/migration/#issue-high-memory-usage-after-migration","title":"Issue: High Memory Usage After Migration","text":"<p>Symptoms: V2 services consuming > 4GB RAM, slow response times.</p> <p>Causes: - Prisma connection pool too large - Redis cache not evicting old entries - Large JSON fields in database (campaign data, page blocks)</p> <p>Solutions: <pre><code># 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</code></pre></p>"},{"location":"v2/migration/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/migration/#migration-guides","title":"Migration Guides","text":"<ul> <li>Breaking Changes - Comprehensive V1\u2192V2 differences</li> <li>Data Migration - Detailed migration procedures</li> <li>API Changes - Endpoint reference mapping</li> <li>Feature Parity - Feature comparison matrix</li> </ul>"},{"location":"v2/migration/#v2-setup-guides","title":"V2 Setup Guides","text":"<ul> <li>Quick Start - 5-minute V2 setup</li> <li>Production Deployment - Production configuration</li> <li>Environment Variables - .env reference</li> </ul>"},{"location":"v2/migration/#v2-architecture","title":"V2 Architecture","text":"<ul> <li>Architecture Overview - System design</li> <li>Dual API Design - Express + Fastify</li> <li>Authentication - JWT flow</li> <li>Database Schema - Prisma models</li> </ul>"},{"location":"v2/migration/#post-migration","title":"Post-Migration","text":"<ul> <li>Admin Guide - Platform administration</li> <li>Monitoring - Prometheus + Grafana</li> <li>Backups - Backup procedures</li> <li>Troubleshooting - Common issues</li> </ul>"},{"location":"v2/migration/#next-steps","title":"Next Steps","text":"<p>Ready to begin migration?</p> <ol> <li>Review Breaking Changes - Understand all V1\u2192V2 differences</li> <li>Plan Data Migration - Create migration timeline</li> <li>Set Up V2 Staging - Test environment</li> <li>Perform Test Migration - Validate process</li> <li>Execute Production Migration - Go live</li> </ol> <p>Migration Support</p> <p>Need help with your migration? Email support@cmlite.org or open a GitHub discussion.</p>"},{"location":"v2/migration/api-changes/","title":"API Endpoint Changes","text":"<p>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.</p>"},{"location":"v2/migration/api-changes/#overview","title":"Overview","text":"<p>V2 API represents a complete redesign with:</p> <ul> <li>RESTful conventions (proper HTTP methods)</li> <li>Unified namespace (single API at <code>/api/*</code>)</li> <li>JWT authentication (Bearer tokens instead of sessions)</li> <li>Zod validation (type-safe request validation)</li> <li>Standardized responses (<code>{ success, data, pagination }</code> structure)</li> </ul> <p>Migration Strategy</p> <p>Update frontend API calls incrementally, starting with authentication (foundational), then module by module (campaigns, locations, shifts, etc.).</p>"},{"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":"<p>V1 Login: <pre><code>// 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</code></pre></p>"},{"location":"v2/migration/api-changes/#v2-authentication-jwt-bearer-tokens","title":"V2 Authentication (JWT Bearer Tokens)","text":"<p>V2 Login: <pre><code>// 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</code></pre></p> <p>V2 Token Refresh: <pre><code>// 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</code></pre></p>"},{"location":"v2/migration/api-changes/#authentication-endpoint-mapping","title":"Authentication Endpoint Mapping","text":"V1 Endpoint V2 Endpoint Method Changes <code>/auth/login</code> <code>/api/auth/login</code> POST Returns JWT tokens instead of setting cookie <code>/auth/logout</code> <code>/api/auth/logout</code> POST Requires <code>refreshToken</code> in body <code>/auth/register</code> <code>/api/auth/register</code> POST Always creates USER role (no role in request) <code>/auth/me</code> <code>/api/auth/me</code> GET Returns 401 if invalid (not 404) - <code>/api/auth/refresh</code> 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":"<pre><code>// 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</code></pre>"},{"location":"v2/migration/api-changes/#v2-campaign-endpoints","title":"V2 Campaign Endpoints","text":"<pre><code>// 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</code></pre>"},{"location":"v2/migration/api-changes/#campaign-response-format-changes","title":"Campaign Response Format Changes","text":"<p>V1 Response: <pre><code>{\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</code></pre></p> <p>V2 Response: <pre><code>{\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</code></pre></p>"},{"location":"v2/migration/api-changes/#representatives","title":"Representatives","text":""},{"location":"v2/migration/api-changes/#v1-representative-endpoints","title":"V1 Representative Endpoints","text":"<pre><code>// Lookup representatives by postal code\nPOST /representatives/lookup\nBody: { postalCode: \"M5V 1A1\" }\n\n// List cached representatives (admin)\nGET /admin/representatives\n</code></pre>"},{"location":"v2/migration/api-changes/#v2-representative-endpoints","title":"V2 Representative Endpoints","text":"<pre><code>// 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</code></pre>"},{"location":"v2/migration/api-changes/#campaign-emails","title":"Campaign Emails","text":""},{"location":"v2/migration/api-changes/#v1-email-endpoints","title":"V1 Email Endpoints","text":"<pre><code>// Send campaign email\nPOST /campaigns/:id/send-email\nBody: { senderName, senderEmail, postalCode }\n</code></pre>"},{"location":"v2/migration/api-changes/#v2-email-endpoints","title":"V2 Email Endpoints","text":"<pre><code>// 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</code></pre>"},{"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":"<pre><code>// 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</code></pre>"},{"location":"v2/migration/api-changes/#response-wall","title":"Response Wall","text":""},{"location":"v2/migration/api-changes/#v1-response-endpoints","title":"V1 Response Endpoints","text":"<pre><code>// Submit response\nPOST /responses/submit\nBody: { campaignId, name, email, message }\n\n// List responses\nGET /responses/:campaignId\n</code></pre>"},{"location":"v2/migration/api-changes/#v2-response-endpoints","title":"V2 Response Endpoints","text":"<pre><code>// 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</code></pre>"},{"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":"<pre><code>// 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</code></pre>"},{"location":"v2/migration/api-changes/#v2-location-endpoints","title":"V2 Location Endpoints","text":"<pre><code>// 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</code></pre>"},{"location":"v2/migration/api-changes/#cuts-territories","title":"Cuts (Territories)","text":""},{"location":"v2/migration/api-changes/#v1-cut-endpoints","title":"V1 Cut Endpoints","text":"<pre><code>// List cuts (admin)\nGET /admin/cuts\n\n// Create cut (admin)\nPOST /admin/cuts/create\nBody: { Name, GeoJSON }\n</code></pre>"},{"location":"v2/migration/api-changes/#v2-cut-endpoints","title":"V2 Cut Endpoints","text":"<pre><code>// 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</code></pre>"},{"location":"v2/migration/api-changes/#shifts","title":"Shifts","text":""},{"location":"v2/migration/api-changes/#v1-shift-endpoints","title":"V1 Shift Endpoints","text":"<pre><code>// 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</code></pre>"},{"location":"v2/migration/api-changes/#v2-shift-endpoints","title":"V2 Shift Endpoints","text":"<pre><code>// 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</code></pre>"},{"location":"v2/migration/api-changes/#canvassing-new-in-v2","title":"Canvassing (New in V2)","text":"<pre><code>// 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</code></pre>"},{"location":"v2/migration/api-changes/#gps-tracking-new-in-v2","title":"GPS Tracking (New in V2)","text":"<pre><code>// 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</code></pre>"},{"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":"<pre><code>// 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</code></pre>"},{"location":"v2/migration/api-changes/#email-templates","title":"Email Templates","text":"<pre><code>// 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</code></pre>"},{"location":"v2/migration/api-changes/#response-format-standards","title":"Response Format Standards","text":""},{"location":"v2/migration/api-changes/#success-response","title":"Success Response","text":"<pre><code>{\n \"success\": true,\n \"data\": { /* response data */ }\n}\n</code></pre>"},{"location":"v2/migration/api-changes/#paginated-response","title":"Paginated Response","text":"<pre><code>{\n \"success\": true,\n \"data\": [ /* items */ ],\n \"pagination\": {\n \"page\": 1,\n \"limit\": 20,\n \"total\": 100,\n \"totalPages\": 5\n }\n}\n</code></pre>"},{"location":"v2/migration/api-changes/#error-response","title":"Error Response","text":"<pre><code>{\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</code></pre>"},{"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":"<p>V1 Code: <pre><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</code></pre></p> <p>V2 Code: <pre><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</code></pre></p>"},{"location":"v2/migration/api-changes/#example-2-location-creation","title":"Example 2: Location Creation","text":"<p>V1 Code: <pre><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</code></pre></p> <p>V2 Code: <pre><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</code></pre></p>"},{"location":"v2/migration/api-changes/#rate-limiting","title":"Rate Limiting","text":"<p>V2 adds rate limiting to prevent abuse:</p> Endpoint Limit Window <code>/api/auth/login</code> 10 requests 1 minute <code>/api/auth/register</code> 10 requests 1 minute <code>/api/influence/campaign-emails/send-email</code> 30 requests 1 hour <code>/api/map/canvass/visits</code> 30 requests 1 minute <p>Rate Limit Headers (V2 only): <pre><code>X-RateLimit-Limit: 10\nX-RateLimit-Remaining: 8\nX-RateLimit-Reset: 1707835200\n</code></pre></p>"},{"location":"v2/migration/api-changes/#related-documentation","title":"Related Documentation","text":"<ul> <li>Migration Overview - Migration planning</li> <li>Breaking Changes - V1\u2192V2 differences</li> <li>Data Migration - Data transfer guide</li> <li>Authentication - JWT flow details</li> <li>API Reference - Full API documentation</li> </ul>"},{"location":"v2/migration/api-changes/#next-steps","title":"Next Steps","text":"<ol> <li>Review endpoint mappings for your application's usage</li> <li>Update API client to use JWT authentication</li> <li>Migrate endpoints incrementally (auth first, then modules)</li> <li>Test error handling with new response format</li> <li>Implement rate limit handling (exponential backoff)</li> </ol> <p>API Testing</p> <p>Use tools like Postman or Thunder Client to test V2 endpoints before frontend migration. Import the V2 API collection from <code>/docs/postman-collection.json</code> (if available).</p>"},{"location":"v2/migration/breaking-changes/","title":"Breaking Changes: V1 to V2","text":"<p>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.</p>"},{"location":"v2/migration/breaking-changes/#overview","title":"Overview","text":"<p>V2 is a clean-room rebuild with fundamental architectural changes. Almost every aspect of the platform has changed, requiring careful planning for migration.</p> <p>Not a Drop-In Replacement</p> <p>V2 cannot be deployed alongside V1 without migration. Database schemas, APIs, and authentication are completely incompatible.</p>"},{"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":"<p>V1 Architecture: <pre><code>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</code></pre></p> <p>V2 Architecture: <pre><code>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</code></pre></p> <p>Impact: V1 had two separate codebases with duplicated auth, middleware, and configuration. V2 consolidates everything into a single unified API.</p>"},{"location":"v2/migration/breaking-changes/#2-data-layer-transformation","title":"2. Data Layer Transformation","text":"Aspect V1 V2 ORM None (direct NocoDB REST API) Prisma ORM + Drizzle (media) Database NocoDB internal PostgreSQL PostgreSQL 16 direct access Migrations NocoDB auto-migrations Prisma migrate Validation Manual (express-validator) Zod schemas Queries HTTP requests to NocoDB <code>prisma.model.findMany()</code> <p>V1 Example (NocoDB REST API): <pre><code>// 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</code></pre></p> <p>V2 Example (Prisma ORM): <pre><code>// api/src/modules/influence/campaigns/campaigns.service.ts\nconst campaigns = await prisma.campaign.findMany({\n where: { active: true },\n include: { createdBy: true }\n});\n</code></pre></p> <p>Impact: All database queries must be rewritten from HTTP requests to Prisma queries. No migration script can automate this.</p>"},{"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":"<p>V1 Authentication: <pre><code>// 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</code></pre></p> <p>V2 Authentication: <pre><code>// 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</code></pre></p> <p>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 <code>Authorization: Bearer <token></code> header instead of cookies.</p>"},{"location":"v2/migration/breaking-changes/#password-hashing","title":"Password Hashing","text":"<p>Compatibility: Both V1 and V2 use bcrypt, so password hashes can migrate directly.</p> <pre><code>// 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</code></pre> <p>Password Migration Safe</p> <p>V1 bcrypt hashes can be copied directly to V2 User.password field. Users can login with existing passwords.</p>"},{"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":"<p>Influence Users (<code>influence_users</code> table): <pre><code>{\n \"id\": 1,\n \"email\": \"admin@example.com\",\n \"password\": \"$2b$10...\",\n \"role\": \"admin\"\n}\n</code></pre></p> <p>Login Users (<code>login</code> table): <pre><code>{\n \"id\": 1,\n \"email\": \"admin@example.com\",\n \"password\": \"$2b$10...\",\n \"name\": \"Admin User\"\n}\n</code></pre></p> <p>Problem: V1 had two separate user tables (one per app) with potential email duplicates.</p>"},{"location":"v2/migration/breaking-changes/#v2-user-model-unified","title":"V2 User Model (Unified)","text":"<pre><code>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</code></pre> <p>Migration Challenges: 1. Email deduplication: Merge <code>influence_users</code> + <code>login</code> where email matches 2. Role mapping: V1 \"admin\" \u2192 V2 <code>SUPER_ADMIN</code>, V1 \"user\" \u2192 V2 <code>USER</code> 3. Missing fields: V2 adds <code>phone</code>, <code>status</code>, <code>createdVia</code>, <code>emailVerified</code> 4. ID format: V1 integer IDs \u2192 V2 CUID strings (breaks foreign keys)</p> <p>Migration Script (conceptual): <pre><code>// 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</code></pre></p>"},{"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 <p>V1 View (EJS template): <pre><code><!-- 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</code></pre></p> <p>V2 Component (React + TypeScript): <pre><code>// 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</code></pre></p> <p>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).</p>"},{"location":"v2/migration/breaking-changes/#api-changes","title":"API Changes","text":""},{"location":"v2/migration/breaking-changes/#endpoint-url-structure","title":"Endpoint URL Structure","text":"<p>V1 Endpoints: <pre><code># 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</code></pre></p> <p>V2 Endpoints: <pre><code># 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</code></pre></p> <p>Changes: 1. All endpoints prefixed with <code>/api/</code> 2. RESTful conventions (GET/POST/PUT/DELETE instead of <code>/create</code>, <code>/edit</code>) 3. Single port (4000) instead of two apps 4. Namespaced by module (<code>/influence/</code>, <code>/map/</code>)</p>"},{"location":"v2/migration/breaking-changes/#requestresponse-format","title":"Request/Response Format","text":"<p>V1 Response (NocoDB-style): <pre><code>{\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</code></pre></p> <p>V2 Response (standardized): <pre><code>{\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</code></pre></p> <p>Changes: - V2 wraps responses in <code>{ success, data, pagination }</code> structure - Field names: camelCase (<code>createdAt</code>) vs mixed case (<code>Created</code>) - IDs: CUID strings vs integers - Timestamps: ISO 8601 with milliseconds</p>"},{"location":"v2/migration/breaking-changes/#authentication-headers","title":"Authentication Headers","text":"<p>V1 Requests: <pre><code>// Session cookie sent automatically\nfetch('/campaigns', {\n method: 'GET',\n credentials: 'include' // Sends session cookie\n});\n</code></pre></p> <p>V2 Requests: <pre><code>// JWT Bearer token required\nfetch('/api/influence/campaigns', {\n method: 'GET',\n headers: {\n 'Authorization': `Bearer ${accessToken}`\n }\n});\n</code></pre></p> <p>Impact: All API calls must be updated to include Authorization header. No more cookie-based authentication.</p>"},{"location":"v2/migration/breaking-changes/#validation-errors","title":"Validation Errors","text":"<p>V1 Validation (express-validator): <pre><code>{\n \"errors\": [\n {\n \"msg\": \"Invalid email\",\n \"param\": \"email\",\n \"location\": \"body\"\n }\n ]\n}\n</code></pre></p> <p>V2 Validation (Zod): <pre><code>{\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</code></pre></p>"},{"location":"v2/migration/breaking-changes/#database-schema-changes","title":"Database Schema Changes","text":""},{"location":"v2/migration/breaking-changes/#campaign-model","title":"Campaign Model","text":"<p>V1 NocoDB Table (<code>campaigns</code>): <pre><code>Columns:\n- Id (integer, auto-increment)\n- Title (string)\n- Description (text)\n- Slug (string)\n- IsActive (boolean)\n- Created (datetime)\n</code></pre></p> <p>V2 Prisma Model: <pre><code>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</code></pre></p> <p>Changes: 1. New fields: <code>highlighted</code>, <code>targetLevel</code>, <code>responseWallEnabled</code>, <code>createdByUserId</code> 2. Relations: Foreign key to User (V1 had no user relation) 3. Renamed: <code>IsActive</code> \u2192 <code>active</code>, <code>Created</code> \u2192 <code>createdAt</code> 4. Type changes: <code>Description</code> text \u2192 <code>String?</code> (nullable)</p>"},{"location":"v2/migration/breaking-changes/#location-model","title":"Location Model","text":"<p>V1 NocoDB Table (<code>locations</code>): <pre><code>Columns:\n- Id (integer)\n- Address (string)\n- Latitude (float)\n- Longitude (float)\n- SupportLevel (string)\n- Notes (text)\n</code></pre></p> <p>V2 Prisma Model: <pre><code>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</code></pre></p> <p>Changes: 1. Structured address: V1 single <code>Address</code> \u2192 V2 <code>address</code>, <code>city</code>, <code>province</code>, <code>postalCode</code> 2. Geocoding metadata: <code>geocoded</code>, <code>geocodedAt</code>, <code>geocodeProvider</code>, <code>geocodeQuality</code> 3. Contact fields: <code>contactName</code>, <code>contactPhone</code>, <code>contactEmail</code> 4. NAR fields: <code>unitNumber</code>, <code>buildingName</code>, <code>buildingUse</code>, <code>federalDistrict</code> 5. Relations: <code>cutId</code>, <code>createdByUserId</code>, <code>updatedByUserId</code> 6. SupportLevel: V1 string \u2192 V2 enum</p>"},{"location":"v2/migration/breaking-changes/#shift-model","title":"Shift Model","text":"<p>V1 NocoDB Table (<code>shifts</code>): <pre><code>Columns:\n- Id (integer)\n- Name (string)\n- StartTime (datetime)\n- EndTime (datetime)\n- Location (string)\n- Capacity (integer)\n</code></pre></p> <p>V2 Prisma Model: <pre><code>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</code></pre></p> <p>Changes: 1. Separate signups: V1 embedded \u2192 V2 <code>ShiftSignup</code> relation table 2. New fields: <code>description</code>, <code>requirements</code>, <code>cutId</code> 3. Signup tracking: <code>status</code>, <code>confirmedAt</code>, <code>cancelledAt</code> 4. Unique constraint: One signup per user per shift</p>"},{"location":"v2/migration/breaking-changes/#configuration-changes","title":"Configuration Changes","text":""},{"location":"v2/migration/breaking-changes/#environment-variables","title":"Environment Variables","text":"<p>V1 Environment (<code>.env</code>): <pre><code># 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</code></pre></p> <p>V2 Environment (<code>.env</code>): <pre><code># 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</code></pre></p> <p>Removed V1 Variables: - <code>NOCODB_URL</code> (no longer using NocoDB as data layer) - <code>NOCODB_API_TOKEN</code> - <code>SESSION_SECRET</code> (replaced by JWT secrets)</p> <p>New V2 Variables: - <code>DATABASE_URL</code> (direct PostgreSQL connection) - <code>JWT_ACCESS_SECRET</code>, <code>JWT_REFRESH_SECRET</code> - <code>ENCRYPTION_KEY</code> (for encrypting sensitive DB fields) - <code>LISTMONK_SYNC_ENABLED</code> (newsletter integration) - <code>ENABLE_MEDIA_FEATURES</code> (media library toggle)</p>"},{"location":"v2/migration/breaking-changes/#docker-compose-changes","title":"Docker Compose Changes","text":"<p>V1 Services: <pre><code>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</code></pre></p> <p>V2 Services: <pre><code>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</code></pre></p> <p>Changes: 1. Removed: <code>influence-app</code>, <code>map-app</code>, <code>nocodb</code> 2. Added: <code>api</code>, <code>media-api</code>, <code>admin</code>, <code>v2-postgres</code>, <code>nginx</code> 3. Port changes: API 3333/3000 \u2192 4000, Admin GUI on 3000 4. Redis: Now requires authentication (<code>--requirepass</code>)</p>"},{"location":"v2/migration/breaking-changes/#code-migration-examples","title":"Code Migration Examples","text":""},{"location":"v2/migration/breaking-changes/#campaign-list-endpoint","title":"Campaign List Endpoint","text":"<p>V1 Implementation: <pre><code>// 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</code></pre></p> <p>V2 Implementation: <pre><code>// 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</code></pre></p> <p>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</p>"},{"location":"v2/migration/breaking-changes/#user-login","title":"User Login","text":"<p>V1 Implementation: <pre><code>// 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</code></pre></p> <p>V2 Implementation: <pre><code>// 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</code></pre></p> <p>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 (<code>ACTIVE</code>, <code>SUSPENDED</code>, etc.) 5. V2: Refresh token storage for rotation 6. V2: Last login tracking</p>"},{"location":"v2/migration/breaking-changes/#deployment-changes","title":"Deployment Changes","text":""},{"location":"v2/migration/breaking-changes/#port-mapping","title":"Port Mapping","text":"Service V1 Port V2 Port Notes Influence App 3333 - Removed Map App 3000 - Removed Admin GUI - 3000 New React app Express API - 4000 New unified API Fastify Media API - 4100 New media service NocoDB 8080 8091 Now read-only browser PostgreSQL (main) - 5433 New V2 database Listmonk - 9001 New newsletter service Grafana - 3001 New monitoring Prometheus - 9090 New metrics"},{"location":"v2/migration/breaking-changes/#nginx-routing","title":"Nginx Routing","text":"<p>V1 Nginx (simple proxy): <pre><code>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</code></pre></p> <p>V2 Nginx (subdomain routing): <pre><code># 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</code></pre></p> <p>Impact: V2 requires DNS configuration for subdomains (<code>app.</code>, <code>api.</code>, <code>media.</code>, etc.).</p>"},{"location":"v2/migration/breaking-changes/#feature-changes","title":"Feature Changes","text":""},{"location":"v2/migration/breaking-changes/#features-removed-in-v2","title":"Features Removed in V2","text":"<ol> <li>NocoDB Data Browser (as primary interface)</li> <li>V2 uses NocoDB only as read-only browser</li> <li> <p>All CRUD operations via API/Admin GUI</p> </li> <li> <p>Embedded EJS Views</p> </li> <li>No server-rendered templates</li> <li> <p>All UI is React SPA</p> </li> <li> <p>Session-Based Multi-Tenancy</p> </li> <li>V1 supported multiple campaigns with session isolation</li> <li>V2 is single-tenant (one installation per organization)</li> </ol>"},{"location":"v2/migration/breaking-changes/#features-added-in-v2","title":"Features Added in V2","text":"<ol> <li>Landing Page Builder</li> <li>GrapesJS visual editor</li> <li>Custom blocks library</li> <li> <p>MkDocs export (Jinja2 templates)</p> </li> <li> <p>Email Templates System</p> </li> <li>Template versioning</li> <li>Variable substitution</li> <li>Live preview</li> <li> <p>HTML + plain text variants</p> </li> <li> <p>Media Library</p> </li> <li>Video upload with FFprobe metadata</li> <li>Public gallery with categories</li> <li>Reaction system (6 emoji types)</li> <li> <p>Bulk operations</p> </li> <li> <p>Volunteer Canvassing</p> </li> <li>GPS tracking sessions</li> <li>Walking route algorithm</li> <li>Visit outcome recording</li> <li> <p>Admin dashboard with leaderboards</p> </li> <li> <p>Data Quality Dashboard</p> </li> <li>Geocoding quality metrics</li> <li>Provider performance comparison</li> <li> <p>Bulk re-geocoding tools</p> </li> <li> <p>Comprehensive Monitoring</p> </li> <li>Prometheus metrics (12 custom <code>cm_*</code> metrics)</li> <li>Grafana dashboards (3 pre-configured)</li> <li>Alertmanager with Gotify integration</li> <li> <p>Docker healthchecks</p> </li> <li> <p>NAR 2025 Import</p> </li> <li>Canadian electoral data import</li> <li>Server-side streaming (large files)</li> <li>Location + Address file joining</li> <li> <p>Province/city/postal filtering</p> </li> <li> <p>Pangolin Tunnel</p> </li> <li>Self-hosted tunnel alternative to Cloudflare</li> <li>Newt container integration</li> <li>Admin setup wizard</li> </ol>"},{"location":"v2/migration/breaking-changes/#features-changed-in-v2","title":"Features Changed in V2","text":"<ol> <li>Campaign Email Sending</li> <li>V1: Bull job queue \u2192 V2: BullMQ with monitoring</li> <li> <p>V1: Single SMTP config \u2192 V2: Test mode + Listmonk integration</p> </li> <li> <p>Response Wall</p> </li> <li> <p>V1: Simple submission form \u2192 V2: Moderation + upvoting + verification</p> </li> <li> <p>Geocoding</p> </li> <li>V1: Single provider (Nominatim) \u2192 V2: 6 providers with fallback</li> <li> <p>V2 adds: ArcGIS, Photon, Mapbox, Google, OpenCage</p> </li> <li> <p>User Roles</p> </li> <li>V1: <code>admin</code>, <code>user</code> \u2192 V2: <code>SUPER_ADMIN</code>, <code>INFLUENCE_ADMIN</code>, <code>MAP_ADMIN</code>, <code>USER</code>, <code>TEMP</code></li> <li>V2: Role-based access control (RBAC) middleware</li> </ol>"},{"location":"v2/migration/breaking-changes/#security-changes","title":"Security Changes","text":""},{"location":"v2/migration/breaking-changes/#enhancements-in-v2","title":"Enhancements in V2","text":"<ol> <li>Password Policy</li> <li> <p>V1: No requirements \u2192 V2: 12+ chars, uppercase, lowercase, digit (Zod schema)</p> </li> <li> <p>Rate Limiting</p> </li> <li> <p>V1: None \u2192 V2: Auth endpoints 10/min per IP, canvass visits 30/min</p> </li> <li> <p>Refresh Token Rotation</p> </li> <li> <p>V1: Static sessions \u2192 V2: Atomic token rotation (prevents replay attacks)</p> </li> <li> <p>User Enumeration Prevention</p> </li> <li> <p>V2: Login returns 401 for both invalid email and password (V1 returned different errors)</p> </li> <li> <p>Redis Authentication</p> </li> <li> <p>V1: No password \u2192 V2: Required <code>REDIS_PASSWORD</code></p> </li> <li> <p>Encryption Key</p> </li> <li> <p>V2: Separate <code>ENCRYPTION_KEY</code> for sensitive DB fields (different from JWT secrets)</p> </li> <li> <p>Input Sanitization</p> </li> <li> <p>V2: HTML escaping for user content (responses, emails, templates)</p> </li> <li> <p>Path Traversal Protection</p> </li> <li>V2: Null byte checks, path normalization, encoded traversal blocking</li> </ol>"},{"location":"v2/migration/breaking-changes/#security-audit","title":"Security Audit","text":"<p>V2 underwent comprehensive security audit (2025-02-11) addressing 13 findings: - 1 Critical, 6 Important, 3 Medium, 2 Low, 1 Suggestion</p> <p>See Security Audit Report for details.</p>"},{"location":"v2/migration/breaking-changes/#performance-considerations","title":"Performance Considerations","text":""},{"location":"v2/migration/breaking-changes/#v1-performance-characteristics","title":"V1 Performance Characteristics","text":"<ul> <li>Database Access: HTTP requests to NocoDB (REST API overhead)</li> <li>N+1 Queries: Common due to REST API pagination</li> <li>Caching: Redis sessions only</li> <li>Concurrency: Limited by Node.js single-threaded event loop</li> </ul>"},{"location":"v2/migration/breaking-changes/#v2-performance-improvements","title":"V2 Performance Improvements","text":"<ol> <li>Direct Database Access</li> <li>Prisma ORM eliminates REST API overhead</li> <li> <p>Connection pooling reduces latency</p> </li> <li> <p>Query Optimization</p> </li> <li>Prisma includes relations in single query (no N+1)</li> <li> <p>Indexed foreign keys, unique constraints</p> </li> <li> <p>Caching Strategy</p> </li> <li>Redis cache for representatives (60min TTL)</li> <li>Redis cache for postal codes (persistent)</li> <li> <p>Prisma query result caching</p> </li> <li> <p>Dual API Architecture</p> </li> <li>Media API (Fastify) handles video uploads separately</li> <li> <p>Prevents main API blocking on large file uploads</p> </li> <li> <p>Monitoring</p> </li> <li>Prometheus <code>http_request_duration_seconds</code> histogram</li> <li>Slow query detection via metrics</li> <li>Grafana alerting on high latency</li> </ol>"},{"location":"v2/migration/breaking-changes/#related-documentation","title":"Related Documentation","text":"<ul> <li>Data Migration Procedures - Step-by-step migration guide</li> <li>API Endpoint Changes - Complete endpoint mapping</li> <li>Feature Parity Matrix - Feature comparison</li> <li>V2 Architecture - System design</li> <li>V2 Database Schema - Prisma models</li> </ul>"},{"location":"v2/migration/breaking-changes/#next-steps","title":"Next Steps","text":"<ol> <li>Review this breaking changes document thoroughly</li> <li>Plan data transformation scripts (user merging, ID mapping)</li> <li>Test authentication migration (password hashes, login flow)</li> <li>Set up V2 staging environment for testing</li> <li>Proceed to Data Migration Guide</li> </ol> <p>Migration Complexity</p> <p>V2 migration is complex due to fundamental architectural changes. Budget 2-4 weeks for planning, scripting, testing, and execution.</p>"},{"location":"v2/migration/data-migration/","title":"Data Migration Procedures","text":"<p>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.</p>"},{"location":"v2/migration/data-migration/#overview","title":"Overview","text":"<p>V2 data migration involves:</p> <ol> <li>Export - Extract data from V1 NocoDB tables</li> <li>Transform - Convert V1 schema to V2 Prisma models</li> <li>Import - Load transformed data into V2 PostgreSQL</li> <li>Validate - Verify data integrity and completeness</li> </ol> <p>Production Migration Warning</p> <p>ALWAYS perform a test migration on a staging environment before production. Data loss is possible if scripts contain errors.</p>"},{"location":"v2/migration/data-migration/#prerequisites","title":"Prerequisites","text":"<p>Before beginning data migration:</p> <ul> <li> V1 backup completed (PostgreSQL dump + uploads)</li> <li> V2 environment running (<code>docker compose up -d v2-postgres redis api</code>)</li> <li> Prisma migrations applied (<code>npx prisma migrate deploy</code>)</li> <li> Node.js 20+ installed (for transformation scripts)</li> <li> Sufficient disk space (3x current database size recommended)</li> <li> Network access (V1 NocoDB API, V2 database)</li> </ul>"},{"location":"v2/migration/data-migration/#data-mapping","title":"Data Mapping","text":""},{"location":"v2/migration/data-migration/#v1-tables-v2-prisma-models","title":"V1 Tables \u2192 V2 Prisma Models","text":"V1 NocoDB Table V2 Prisma Model Notes <code>influence_users</code> <code>User</code> Merge with <code>login</code> table <code>login</code> <code>User</code> Merge with <code>influence_users</code> <code>campaigns</code> <code>Campaign</code> Add <code>createdByUserId</code> relation <code>representatives</code> <code>Representative</code> Direct migration <code>responses</code> <code>RepresentativeResponse</code> Add verification fields <code>response_upvotes</code> <code>ResponseUpvote</code> Add IP dedup field <code>postal_code_cache</code> <code>PostalCodeCache</code> Direct migration <code>locations</code> <code>Location</code> Split address, add geocoding fields <code>shifts</code> <code>Shift</code> Extract signups to <code>ShiftSignup</code> <code>shift_signups</code> <code>ShiftSignup</code> Add status enum <code>cuts</code> <code>Cut</code> Parse GeoJSON coordinates (none) <code>RefreshToken</code> New in V2 (generated on first login) (none) <code>SiteSettings</code> New in V2 (seed with defaults) (none) <code>MapSettings</code> 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 (<code>influence_users</code>) V1 Field (<code>login</code>) V2 Field Transformation <code>Id</code> <code>Id</code> - Discard (V2 uses CUID) <code>Email</code> <code>Email</code> <code>email</code> Merge by email, enforce unique <code>Password</code> <code>Password</code> <code>password</code> Bcrypt hash (direct copy) - <code>Name</code> <code>name</code> From <code>login.Name</code> - - <code>phone</code> NULL (not in V1) <code>Role</code> - <code>role</code> Map: 'admin'\u2192'SUPER_ADMIN', 'user'\u2192'USER' - - <code>status</code> Default: 'ACTIVE' - - <code>createdVia</code> Default: 'STANDARD' - - <code>expiresAt</code> NULL - - <code>emailVerified</code> Default: false <code>Created</code> <code>Created</code> <code>createdAt</code> ISO 8601 timestamp - - <code>updatedAt</code> Use <code>createdAt</code> or current time <p>Merge Logic: <pre><code>// 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</code></pre></p>"},{"location":"v2/migration/data-migration/#campaigns","title":"Campaigns","text":"V1 Field V2 Field Transformation <code>Id</code> - Discard (use CUID) <code>Title</code> <code>title</code> Direct copy <code>Description</code> <code>description</code> Direct copy <code>Slug</code> <code>slug</code> Direct copy <code>IsActive</code> <code>active</code> Boolean conversion - <code>highlighted</code> Default: false <code>TargetLevel</code> <code>targetLevel</code> Direct copy or NULL <code>TargetPosition</code> <code>targetPosition</code> Direct copy or NULL - <code>targetName</code> NULL (not in V1) - <code>targetEmail</code> NULL - <code>targetPostalCode</code> NULL - <code>customSubject</code> NULL - <code>customBody</code> NULL - <code>responseWallEnabled</code> Default: true <code>Created</code> <code>createdAt</code> ISO 8601 timestamp - <code>updatedAt</code> Use <code>createdAt</code> - <code>createdByUserId</code> Requires user lookup <p>CreatedBy Mapping: <pre><code>// 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</code></pre></p>"},{"location":"v2/migration/data-migration/#locations","title":"Locations","text":"V1 Field V2 Field Transformation <code>Id</code> - Discard (use CUID) <code>Address</code> <code>address</code>, <code>city</code>, <code>province</code>, <code>postalCode</code> Parse address string - <code>addressLine2</code> NULL - <code>country</code> Default: 'Canada' <code>Latitude</code> <code>latitude</code> Float conversion <code>Longitude</code> <code>longitude</code> Float conversion - <code>geocoded</code> <code>latitude != NULL && longitude != NULL</code> - <code>geocodedAt</code> Use <code>createdAt</code> if geocoded - <code>geocodeProvider</code> 'Legacy V1' or NULL - <code>geocodeQuality</code> NULL (unknown) <code>SupportLevel</code> <code>supportLevel</code> Map string to enum <code>Notes</code> <code>notes</code> Direct copy - <code>contactName</code> NULL - <code>contactPhone</code> NULL - <code>contactEmail</code> NULL - <code>cutId</code> NULL (assign later if needed) <code>Created</code> <code>createdAt</code> ISO 8601 timestamp - <code>updatedAt</code> Use <code>createdAt</code> - <code>createdByUserId</code> First MAP_ADMIN or SUPER_ADMIN <p>Address Parsing: <pre><code>// 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</code></pre></p> <p>SupportLevel Enum Mapping: <pre><code>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</code></pre></p>"},{"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":"<p>Script: <code>scripts/export-v1-nocodb.js</code></p> <pre><code>#!/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</code></pre> <p>Usage: <pre><code>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</code></pre></p>"},{"location":"v2/migration/data-migration/#option-2-postgresql-direct-export","title":"Option 2: PostgreSQL Direct Export","text":"<p>If you have direct access to V1 PostgreSQL database:</p> <pre><code># 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</code></pre>"},{"location":"v2/migration/data-migration/#backup-file-uploads","title":"Backup File Uploads","text":"<pre><code># 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</code></pre>"},{"location":"v2/migration/data-migration/#transform-data","title":"Transform Data","text":""},{"location":"v2/migration/data-migration/#user-transformation","title":"User Transformation","text":"<p>Script: <code>scripts/transform-users.js</code></p> <pre><code>#!/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</code></pre>"},{"location":"v2/migration/data-migration/#campaign-transformation","title":"Campaign Transformation","text":"<p>Script: <code>scripts/transform-campaigns.js</code></p> <pre><code>#!/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</code></pre>"},{"location":"v2/migration/data-migration/#location-transformation","title":"Location Transformation","text":"<p>Script: <code>scripts/transform-locations.js</code></p> <pre><code>#!/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</code></pre>"},{"location":"v2/migration/data-migration/#import-v2-data","title":"Import V2 Data","text":""},{"location":"v2/migration/data-migration/#import-script","title":"Import Script","text":"<p>Script: <code>scripts/import-v2-data.js</code></p> <pre><code>#!/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</code></pre> <p>Usage: <pre><code>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</code></pre></p>"},{"location":"v2/migration/data-migration/#validate-migration","title":"Validate Migration","text":""},{"location":"v2/migration/data-migration/#validation-script","title":"Validation Script","text":"<p>Script: <code>scripts/validate-migration.js</code></p> <pre><code>#!/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</code></pre>"},{"location":"v2/migration/data-migration/#special-cases","title":"Special Cases","text":""},{"location":"v2/migration/data-migration/#handling-duplicate-emails","title":"Handling Duplicate Emails","text":"<p>During user merge, you may encounter duplicate emails:</p> <pre><code>// 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</code></pre>"},{"location":"v2/migration/data-migration/#migrating-representative-cache","title":"Migrating Representative Cache","text":"<p>Representative cache can be rebuilt from Represent API, but to preserve it:</p> <pre><code>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</code></pre>"},{"location":"v2/migration/data-migration/#migrating-shift-signups","title":"Migrating Shift Signups","text":"<p>V1 may have embedded signups; V2 uses separate <code>ShiftSignup</code> table:</p> <pre><code>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</code></pre>"},{"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":"<p>Before production migration, perform full test on staging:</p> <pre><code># 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</code></pre>"},{"location":"v2/migration/data-migration/#test-critical-workflows","title":"Test Critical Workflows","text":"<p>Script: <code>scripts/test-v2-workflows.sh</code></p> <pre><code>#!/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</code></pre>"},{"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":"<ol> <li> <p>Announce Downtime Window <pre><code>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</code></pre></p> </li> <li> <p>Backup V1 <pre><code>./scripts/backup.sh --include-uploads\n\n# Verify backup\ntar -tzf backups/changemaker-v1-$(date +%Y%m%d).tar.gz | head -20\n</code></pre></p> </li> <li> <p>Test V2 on Staging (use procedure above)</p> </li> </ol>"},{"location":"v2/migration/data-migration/#phase-2-export-t-60min","title":"Phase 2: Export (T-60min)","text":"<ol> <li> <p>Enable V1 Read-Only Mode <pre><code># Stop V1 write services\ndocker compose -f docker-compose.v1.yml stop influence-app map-app\n\n# Keep database running for export\n</code></pre></p> </li> <li> <p>Export V1 Data <pre><code>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</code></pre></p> </li> </ol>"},{"location":"v2/migration/data-migration/#phase-3-transform-t-30min","title":"Phase 3: Transform (T-30min)","text":"<ol> <li>Transform Data <pre><code>node scripts/transform-users.js\nnode scripts/transform-campaigns.js\nnode scripts/transform-locations.js\nnode scripts/transform-shifts.js\n\n# Verify transformed data\nls -lh v2-import/\n</code></pre></li> </ol>"},{"location":"v2/migration/data-migration/#phase-4-import-t-15min","title":"Phase 4: Import (T-15min)","text":"<ol> <li> <p>Stop V1 Completely <pre><code>docker compose -f docker-compose.v1.yml down\n</code></pre></p> </li> <li> <p>Start V2 Database <pre><code>docker compose up -d v2-postgres redis\ndocker compose exec api npx prisma migrate deploy\n</code></pre></p> </li> <li> <p>Import Data <pre><code>node scripts/import-v2-data.js | tee migration.log\n</code></pre></p> </li> <li> <p>Validate Import <pre><code>node scripts/validate-migration.js\n</code></pre></p> </li> </ol>"},{"location":"v2/migration/data-migration/#phase-5-launch-v2-t0min","title":"Phase 5: Launch V2 (T+0min)","text":"<ol> <li> <p>Start All V2 Services <pre><code>docker compose up -d\n\n# Wait for health checks\nsleep 30\n\n# Verify all healthy\ndocker compose ps\n</code></pre></p> </li> <li> <p>Smoke Test <pre><code>./scripts/test-v2-workflows.sh\n</code></pre></p> </li> <li> <p>Update DNS/Tunnel</p> <ul> <li>Pangolin: Update endpoint in admin</li> <li>Cloudflare: Update tunnel configuration</li> <li>Manual DNS: Update A/CNAME records</li> </ul> </li> </ol>"},{"location":"v2/migration/data-migration/#phase-6-monitor-t15min-to-t24hr","title":"Phase 6: Monitor (T+15min to T+24hr)","text":"<ol> <li> <p>Watch Logs <pre><code>docker compose logs -f api admin\n</code></pre></p> </li> <li> <p>Monitor Metrics</p> <ul> <li>Open Grafana: http://localhost:3001</li> <li>Check API Performance dashboard</li> <li>Watch for error spikes</li> </ul> </li> <li> <p>Test User Logins</p> <ul> <li>Admin login</li> <li>Regular user login</li> <li>Temp user creation (shift signup)</li> </ul> </li> <li> <p>Announce Migration Complete <pre><code>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</code></pre></p> </li> </ol>"},{"location":"v2/migration/data-migration/#rollback-procedures","title":"Rollback Procedures","text":"<p>If migration fails, follow these steps:</p>"},{"location":"v2/migration/data-migration/#emergency-rollback-t0-to-t2hr","title":"Emergency Rollback (T+0 to T+2hr)","text":"<pre><code># 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</code></pre>"},{"location":"v2/migration/data-migration/#post-rollback-analysis","title":"Post-Rollback Analysis","text":"<ol> <li> <p>Review Migration Logs <pre><code>cat migration.log | grep ERROR\n</code></pre></p> </li> <li> <p>Identify Root Cause</p> </li> <li>Data transformation errors?</li> <li>Database constraint violations?</li> <li> <p>Application bugs?</p> </li> <li> <p>Fix Issues on Staging</p> </li> <li>Update transformation scripts</li> <li>Test again on staging</li> <li> <p>Validate thoroughly</p> </li> <li> <p>Reschedule Migration</p> </li> <li>New downtime window</li> <li>Communicate lessons learned</li> </ol>"},{"location":"v2/migration/data-migration/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/migration/data-migration/#issue-prisma-unique-constraint-violation","title":"Issue: Prisma Unique Constraint Violation","text":"<p>Error: <code>P2002: Unique constraint failed on the constraint: unique_email</code></p> <p>Cause: Duplicate emails in merged user data.</p> <p>Solution: <pre><code>// 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</code></pre></p>"},{"location":"v2/migration/data-migration/#issue-foreign-key-constraint-violation","title":"Issue: Foreign Key Constraint Violation","text":"<p>Error: <code>P2003: Foreign key constraint failed on the field: createdByUserId</code></p> <p>Cause: Campaign references user that doesn't exist (import order).</p> <p>Solution: Always import in order: 1. Users first 2. Campaigns (references users) 3. Locations (references users) 4. Shifts, responses, etc.</p>"},{"location":"v2/migration/data-migration/#issue-bcrypt-hashes-not-working","title":"Issue: Bcrypt Hashes Not Working","text":"<p>Symptoms: Users can't login after migration despite correct password.</p> <p>Cause: Password field truncated or corrupted.</p> <p>Diagnosis: <pre><code>-- Check password hash format\nSELECT email, LEFT(password, 10), LENGTH(password) FROM \"User\" LIMIT 5;\n\n-- Should be: \"$2b$10...\", length 60\n</code></pre></p> <p>Solution: <pre><code># 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</code></pre></p>"},{"location":"v2/migration/data-migration/#related-documentation","title":"Related Documentation","text":"<ul> <li>Migration Overview - Migration planning guide</li> <li>Breaking Changes - V1\u2192V2 differences</li> <li>API Changes - Endpoint mapping</li> <li>Feature Parity - Feature comparison</li> </ul>"},{"location":"v2/migration/data-migration/#next-steps","title":"Next Steps","text":"<p>After successful migration:</p> <ol> <li>Configure V2 Settings</li> <li>Site Settings</li> <li>Map Settings</li> <li> <p>Email Configuration</p> </li> <li> <p>Train Administrators</p> </li> <li>Admin Guide</li> <li>Campaign Management</li> <li> <p>Volunteer Canvassing</p> </li> <li> <p>Enable New Features</p> </li> <li>Landing Page Builder</li> <li>Email Templates</li> <li> <p>Media Library</p> </li> <li> <p>Set Up Monitoring</p> </li> <li>Observability Guide</li> <li>Backup Procedures</li> </ol> <p>Migration Complete</p> <p>Congratulations on completing your V2 migration! Welcome to the modern Changemaker Lite platform.</p>"},{"location":"v2/migration/feature-parity/","title":"Feature Parity: V1 vs V2","text":"<p>This document provides a comprehensive comparison of features between Changemaker Lite V1 and V2, including feature status, implementation differences, and migration priorities.</p>"},{"location":"v2/migration/feature-parity/#overview","title":"Overview","text":"<p>V2 achieves 100% feature parity with V1 core functionality and adds significant new capabilities. Some V1 features are implemented differently (better!) in V2.</p> <p>V2 Feature Status</p> <ul> <li>\u2705 All V1 Core Features: Campaigns, locations, shifts, response wall</li> <li>\u2705 Enhanced Features: Multi-provider geocoding, canvassing with GPS, monitoring</li> <li>\u2705 New Features: Landing pages, email templates, media library, NAR import</li> </ul>"},{"location":"v2/migration/feature-parity/#feature-comparison-matrix","title":"Feature Comparison Matrix","text":""},{"location":"v2/migration/feature-parity/#core-features","title":"Core Features","text":"Feature V1 V2 Status Notes Email Advocacy Campaigns \u2705 \u2705 Enhanced V2 adds BullMQ queue, Listmonk sync Representative Lookup \u2705 \u2705 Enhanced V2 adds caching, multi-level support Response Wall \u2705 \u2705 Enhanced V2 adds moderation, upvoting, verification Location Management \u2705 \u2705 Enhanced V2 adds structured address, geocoding quality Geocoding \u2705 \u2705 Enhanced V1: Nominatim only \u2192 V2: 6 providers Volunteer Shifts \u2705 \u2705 Enhanced V2 adds cut assignments, status tracking Public Shift Signup \u2705 \u2705 Same V2 creates temp users automatically User Management \u2705 \u2705 Enhanced V2 adds unified user model, RBAC Admin Authentication \u2705 \u2705 Changed V1: Sessions \u2192 V2: JWT"},{"location":"v2/migration/feature-parity/#map-features","title":"Map Features","text":"Feature V1 V2 Status Notes Location Map (Public) \u2705 \u2705 Enhanced V2 adds color-coded markers, cut overlays Location Map (Admin) \u2705 \u2705 Enhanced V2 adds click-to-add, move mode, geolocate Cuts (Territories) \u2705 \u2705 Enhanced V2 adds drawing mode, point-in-polygon CSV Import/Export \u2705 \u2705 Enhanced V2 adds flexible column mapping Bulk Geocoding \u274c \u2705 New V2 adds bulk geocode endpoint Reverse Geocoding \u274c \u2705 New V2 adds lat/lng \u2192 address lookup Walk Sheets \u274c \u2705 New V2 adds printable walk sheets with QR codes Cut Export \u274c \u2705 New V2 adds printable location reports NAR Import \u274c \u2705 New V2 adds Canadian electoral data import Data Quality Dashboard \u274c \u2705 New V2 adds geocoding quality metrics"},{"location":"v2/migration/feature-parity/#canvassing-features","title":"Canvassing Features","text":"Feature V1 V2 Status Notes Canvassing System \u274c \u2705 New V2 adds full canvassing workflow GPS Tracking \u274c \u2705 New V2 adds volunteer GPS trail recording Walking Routes \u274c \u2705 New V2 adds optimized route algorithm Visit Recording \u274c \u2705 New V2 adds outcome tracking, notes Canvass Dashboard \u274c \u2705 New V2 adds admin analytics, leaderboards Volunteer Portal \u274c \u2705 New V2 adds dedicated volunteer interface Activity History \u274c \u2705 New V2 adds visit history, stats"},{"location":"v2/migration/feature-parity/#content-management","title":"Content Management","text":"Feature V1 V2 Status Notes Landing Page Builder \u274c \u2705 New V2 adds GrapesJS editor Block Library \u274c \u2705 New V2 adds reusable content blocks MkDocs Export \u274c \u2705 New V2 adds static site generation Email Templates \u274c \u2705 New V2 adds template system with versioning Template Variables \u274c \u2705 New V2 adds dynamic content substitution"},{"location":"v2/migration/feature-parity/#media-management","title":"Media Management","text":"Feature V1 V2 Status Notes Video Library \u274c \u2705 New V2 adds video CRUD, categories Video Upload \u274c \u2705 New V2 adds upload with metadata extraction Public Gallery \u274c \u2705 New V2 adds public video gallery Reactions \u274c \u2705 New V2 adds 6 emoji reactions Video Sharing \u274c \u2705 New V2 adds lock/unlock system"},{"location":"v2/migration/feature-parity/#email-newsletters","title":"Email & Newsletters","text":"Feature V1 V2 Status Notes SMTP Email Sending \u2705 \u2705 Enhanced V2 adds BullMQ queue, test mode Email Queue \u2705 \u2705 Enhanced V1: Bull \u2192 V2: BullMQ with monitoring Email Tracking \u2705 \u2705 Enhanced V2 adds sent/failed stats per campaign Listmonk Integration \u274c \u2705 New V2 adds newsletter sync Subscriber Management \u274c \u2705 New V2 adds campaign participant \u2192 list sync"},{"location":"v2/migration/feature-parity/#monitoring-devops","title":"Monitoring & DevOps","text":"Feature V1 V2 Status Notes Prometheus Metrics \u274c \u2705 New V2 adds 12 custom <code>cm_*</code> 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":"<pre><code>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</code></pre>"},{"location":"v2/migration/feature-parity/#v2-implementation","title":"V2 Implementation","text":"<pre><code>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</code></pre> <p>Migration Impact: V1 campaigns migrate directly. New fields default to sensible values.</p>"},{"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":"<pre><code>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</code></pre>"},{"location":"v2/migration/feature-parity/#v2-implementation_1","title":"V2 Implementation","text":"<pre><code>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</code></pre> <p>Migration Impact: Representative cache can be migrated or rebuilt from API.</p>"},{"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":"<pre><code>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</code></pre>"},{"location":"v2/migration/feature-parity/#v2-implementation_2","title":"V2 Implementation","text":"<pre><code>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</code></pre> <p>Migration Impact: V1 single address field parsed into structured fields. Geocoding metadata added.</p>"},{"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":"<pre><code>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</code></pre>"},{"location":"v2/migration/feature-parity/#v2-implementation_3","title":"V2 Implementation","text":"<pre><code>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</code></pre> <p>Migration Impact: V1 shifts migrate. Signups extracted to separate table. Status defaults to CONFIRMED.</p>"},{"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":"<pre><code>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</code></pre> <p>Migration Impact: New feature, no V1 equivalent.</p>"},{"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":"<pre><code>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</code></pre> <p>Migration Impact: New feature, no V1 equivalent.</p>"},{"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":"<pre><code>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</code></pre> <p>Migration Impact: New feature, no V1 equivalent.</p>"},{"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":"<pre><code>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</code></pre> <p>Migration Impact: New feature, no V1 equivalent.</p>"},{"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":"<pre><code>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</code></pre> <p>Migration Impact: New feature, no V1 equivalent. Enable with <code>--profile monitoring</code>.</p>"},{"location":"v2/migration/feature-parity/#feature-status-summary","title":"Feature Status Summary","text":""},{"location":"v2/migration/feature-parity/#v1-features-in-v2","title":"V1 Features in V2","text":"Feature V2 Status Implementation Campaigns \u2705 Complete Enhanced with highlighting, response wall toggle Representative Lookup \u2705 Complete Enhanced with caching, stats Response Wall \u2705 Complete Enhanced with moderation, upvoting Locations \u2705 Complete Enhanced with structured address, multi-provider geocoding Shifts \u2705 Complete Enhanced with cut assignment, status tracking Public Shift Signup \u2705 Complete Same functionality, improved UX User Management \u2705 Complete Enhanced with unified model, RBAC Email Sending \u2705 Complete Enhanced with BullMQ, monitoring CSV Import/Export \u2705 Complete Enhanced with flexible mapping <p>Result: 100% V1 feature parity achieved</p>"},{"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":"<p>When migrating from V1 to V2, prioritize features in this order:</p>"},{"location":"v2/migration/feature-parity/#1-critical-must-migrate-first","title":"1. Critical (Must Migrate First)","text":"<ul> <li> User Authentication - Foundational for all access</li> <li> User Management - Admin accounts</li> <li> Campaigns - Core advocacy feature</li> <li> Locations - Core mapping feature</li> <li> Representative Lookup - Core advocacy feature</li> </ul>"},{"location":"v2/migration/feature-parity/#2-high-priority-migrate-early","title":"2. High Priority (Migrate Early)","text":"<ul> <li> Response Wall - Public engagement</li> <li> Email Sending - Campaign functionality</li> <li> Shift Management - Volunteer coordination</li> <li> Public Shift Signup - Volunteer onboarding</li> </ul>"},{"location":"v2/migration/feature-parity/#3-medium-priority-migrate-mid-phase","title":"3. Medium Priority (Migrate Mid-Phase)","text":"<ul> <li> Representative Cache - Performance optimization</li> <li> Postal Code Cache - Performance optimization</li> <li> Cuts (Territories) - Advanced mapping</li> <li> CSV Import/Export - Bulk operations</li> </ul>"},{"location":"v2/migration/feature-parity/#4-low-priority-migrate-later","title":"4. Low Priority (Migrate Later)","text":"<ul> <li> Email Queue Monitoring - Admin analytics</li> <li> Campaign Email Tracking - Admin analytics</li> <li> Representative Admin - Cache management</li> </ul>"},{"location":"v2/migration/feature-parity/#5-optional-new-v2-features","title":"5. Optional (New V2 Features)","text":"<ul> <li> Landing Pages - Public content</li> <li> Email Templates - Email customization</li> <li> Media Library - Video management</li> <li> Canvassing - Field operations</li> <li> Monitoring - System observability</li> <li> NAR Import - Canadian data</li> </ul>"},{"location":"v2/migration/feature-parity/#workarounds-for-missing-features","title":"Workarounds for Missing Features","text":"<p>If you need a V1 feature not yet migrated:</p>"},{"location":"v2/migration/feature-parity/#1-run-v1-and-v2-in-parallel","title":"1. Run V1 and V2 in Parallel","text":"<pre><code># 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</code></pre>"},{"location":"v2/migration/feature-parity/#2-manual-data-entry","title":"2. Manual Data Entry","text":"<p>For small datasets, manually re-enter data in V2 admin:</p> <ul> <li>Campaigns: Use Campaigns page (CRUD)</li> <li>Locations: Use Locations page or CSV import</li> <li>Shifts: Use Shifts page (CRUD)</li> </ul>"},{"location":"v2/migration/feature-parity/#3-custom-migration-scripts","title":"3. Custom Migration Scripts","text":"<p>For unique V1 customizations, write custom transformation scripts:</p> <pre><code>// scripts/migrate-custom-fields.js\nconst customFieldMapping = {\n v1Field: 'v2Field',\n // Add your mappings\n};\n\n// Transform and import\n</code></pre>"},{"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":"<ul> <li> Multi-tenancy - Multiple organizations per instance</li> <li> Mobile apps - iOS/Android native apps</li> <li> Advanced analytics - Campaign performance, volunteer metrics</li> <li> AI integration - Campaign suggestions, email drafting</li> <li> Social media integration - Share campaigns, auto-post</li> <li> SMS campaigns - Text message advocacy</li> <li> Phone banking - Call tracking, scripts</li> <li> Donation tracking - Fundraising integration</li> <li> Event management - Rally, town hall scheduling</li> </ul>"},{"location":"v2/migration/feature-parity/#community-feature-requests","title":"Community Feature Requests","text":"<p>Vote on features at: https://github.com/changemaker-lite/v2/discussions</p>"},{"location":"v2/migration/feature-parity/#related-documentation","title":"Related Documentation","text":"<ul> <li>Migration Overview - Migration planning</li> <li>Breaking Changes - V1\u2192V2 differences</li> <li>Data Migration - Migration procedures</li> <li>V2 Features - Complete feature documentation</li> </ul>"},{"location":"v2/migration/feature-parity/#next-steps","title":"Next Steps","text":"<ol> <li>Review feature matrix - Identify features you use</li> <li>Prioritize migration - Critical features first</li> <li>Test on staging - Verify feature parity</li> <li>Provide feedback - Report missing features</li> <li>Plan new feature adoption - Landing pages, canvassing, etc.</li> </ol> <p>Feature Parity Achieved</p> <p>V2 provides 100% V1 feature parity plus significant new capabilities. No functionality will be lost in migration.</p>"},{"location":"v2/troubleshooting/","title":"Troubleshooting Guide","text":"<p>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.</p>"},{"location":"v2/troubleshooting/#quick-reference","title":"Quick Reference","text":""},{"location":"v2/troubleshooting/#common-errors","title":"Common Errors","text":"<p>Frequently encountered error messages:</p> <ul> <li>Error codes and meanings</li> <li>Stack trace interpretation</li> <li>Quick fixes</li> <li>When to escalate</li> </ul>"},{"location":"v2/troubleshooting/#faq","title":"FAQ","text":"<p>Frequently asked questions:</p> <ul> <li>Installation questions</li> <li>Configuration questions</li> <li>Feature questions</li> <li>Troubleshooting tips</li> </ul>"},{"location":"v2/troubleshooting/#docker-issues","title":"Docker Issues","text":"<p>Container and orchestration problems:</p> <ul> <li>Container won't start</li> <li>Port conflicts</li> <li>Volume permission errors</li> <li>Network connectivity</li> <li>Resource constraints</li> </ul>"},{"location":"v2/troubleshooting/#database-issues","title":"Database Issues","text":"<p>PostgreSQL and Prisma problems:</p> <ul> <li>Connection errors</li> <li>Migration failures</li> <li>Query performance</li> <li>Data corruption</li> <li>Backup/restore issues</li> </ul>"},{"location":"v2/troubleshooting/#authentication-issues","title":"Authentication Issues","text":"<p>Login and permission problems:</p> <ul> <li>Can't log in</li> <li>Token expired</li> <li>Invalid credentials</li> <li>Role permission denied</li> <li>Session management</li> </ul>"},{"location":"v2/troubleshooting/#email-issues","title":"Email Issues","text":"<p>Email delivery problems:</p> <ul> <li>SMTP connection failed</li> <li>Emails not sending</li> <li>Queue backed up</li> <li>Template errors</li> <li>Test mode not working</li> </ul>"},{"location":"v2/troubleshooting/#geocoding-issues","title":"Geocoding Issues","text":"<p>Address geocoding problems:</p> <ul> <li>Geocoding fails</li> <li>Wrong coordinates</li> <li>Provider errors</li> <li>Rate limiting</li> <li>Bulk geocoding stuck</li> </ul>"},{"location":"v2/troubleshooting/#monitoring-issues","title":"Monitoring Issues","text":"<p>Observability and metrics problems:</p> <ul> <li>Prometheus not scraping</li> <li>Grafana dashboard errors</li> <li>Alert not firing</li> <li>Metrics missing</li> <li>Service health incorrect</li> </ul>"},{"location":"v2/troubleshooting/#performance-optimization","title":"Performance Optimization","text":"<p>Speed and efficiency improvements:</p> <ul> <li>Slow API responses</li> <li>Database query optimization</li> <li>Frontend performance</li> <li>Cache optimization</li> <li>Resource usage</li> </ul>"},{"location":"v2/troubleshooting/#common-issues","title":"Common Issues","text":""},{"location":"v2/troubleshooting/#installation-problems","title":"Installation Problems","text":"<p>Symptom: Docker containers fail to start</p> <p>Common Causes: - Port conflicts - Missing environment variables - Insufficient resources - Corrupted volumes</p> <p>Solutions: 1. Check port availability: <code>netstat -tulpn | grep <port></code> 2. Verify <code>.env</code> file exists and is complete 3. Increase Docker memory/CPU limits 4. Remove volumes: <code>docker compose down -v</code></p> <p>Symptom: Database migration fails</p> <p>Common Causes: - Database not running - Connection string incorrect - Migration conflict - Permission issues</p> <p>Solutions: 1. Verify PostgreSQL is running: <code>docker compose ps</code> 2. Check <code>DATABASE_URL</code> in <code>.env</code> 3. Reset database (dev only): <code>npx prisma migrate reset</code> 4. Check user permissions</p> <p>Symptom: \"Cannot connect to Redis\"</p> <p>Common Causes: - Redis not started - Wrong password - Port conflict - Network issue</p> <p>Solutions: 1. Start Redis: <code>docker compose up -d redis</code> 2. Verify <code>REDIS_PASSWORD</code> matches in all services 3. Check port 6379 not in use 4. Test connection: <code>docker compose exec redis redis-cli ping</code></p>"},{"location":"v2/troubleshooting/#runtime-problems","title":"Runtime Problems","text":"<p>Symptom: API returns 500 errors</p> <p>Common Causes: - Unhandled exception - Database query error - Service unavailable - Configuration issue</p> <p>Solutions: 1. Check API logs: <code>docker compose logs -f api</code> 2. Review error stack trace 3. Test database connection 4. Verify environment variables</p> <p>Symptom: Frontend shows blank page</p> <p>Common Causes: - Build error - API not reachable - CORS issue - JavaScript error</p> <p>Solutions: 1. Check browser console (F12) 2. Verify <code>VITE_API_URL</code> in <code>.env</code> 3. Check nginx CORS headers 4. Rebuild admin: <code>docker compose build admin</code></p> <p>Symptom: Emails not sending</p> <p>Common Causes: - SMTP credentials wrong - Test mode enabled - Queue worker not running - Network blocked</p> <p>Solutions: 1. Check <code>EMAIL_TEST_MODE</code> setting 2. Verify SMTP settings in <code>.env</code> 3. Check email queue: <code>docker compose logs -f api | grep email</code> 4. Test with MailHog (port 8025)</p>"},{"location":"v2/troubleshooting/#configuration-issues","title":"Configuration Issues","text":"<p>Symptom: Subdomain routing not working</p> <p>Common Causes: - Nginx config error - DNS not set up - Tunnel not configured - Certificate issue</p> <p>Solutions: 1. Check nginx config: <code>docker compose exec nginx nginx -t</code> 2. Verify DNS records 3. Review tunnel status in Pangolin page 4. Check SSL certificate validity</p> <p>Symptom: Feature not working (media, listmonk, etc.)</p> <p>Common Causes: - Feature flag disabled - Service not started - API credentials missing - Integration not configured</p> <p>Solutions: 1. Check feature flag in <code>.env</code> (e.g., <code>ENABLE_MEDIA_FEATURES</code>) 2. Start required services: <code>docker compose up -d <service></code> 3. Verify API keys/credentials 4. Complete setup wizard in admin</p>"},{"location":"v2/troubleshooting/#diagnostic-commands","title":"Diagnostic Commands","text":""},{"location":"v2/troubleshooting/#check-service-status","title":"Check Service Status","text":"<pre><code># 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</code></pre>"},{"location":"v2/troubleshooting/#test-connectivity","title":"Test Connectivity","text":"<pre><code># 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</code></pre>"},{"location":"v2/troubleshooting/#database-diagnostics","title":"Database Diagnostics","text":"<pre><code># 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</code></pre>"},{"location":"v2/troubleshooting/#view-logs","title":"View Logs","text":"<pre><code># 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</code></pre>"},{"location":"v2/troubleshooting/#resource-usage","title":"Resource Usage","text":"<pre><code># Docker stats\ndocker stats\n\n# Disk usage\ndocker system df\n\n# Container resource limits\ndocker compose config | grep mem_limit\n</code></pre>"},{"location":"v2/troubleshooting/#error-message-reference","title":"Error Message Reference","text":""},{"location":"v2/troubleshooting/#database-errors","title":"Database Errors","text":"<p><pre><code>P2002: Unique constraint failed\n</code></pre> Cause: Duplicate value for unique field (email, slug, etc.) Fix: Use different value or update existing record</p> <p><pre><code>P2025: Record not found\n</code></pre> Cause: Trying to access non-existent record Fix: Verify ID exists, check deletion</p> <p><pre><code>P2021: Table does not exist\n</code></pre> Cause: Missing migration Fix: Run <code>npx prisma migrate deploy</code></p>"},{"location":"v2/troubleshooting/#api-errors","title":"API Errors","text":"<p><pre><code>401 Unauthorized\n</code></pre> Cause: Missing/invalid JWT token Fix: Login again, check token expiration</p> <p><pre><code>403 Forbidden\n</code></pre> Cause: Insufficient permissions Fix: Check user role, verify RBAC middleware</p> <p><pre><code>429 Too Many Requests\n</code></pre> Cause: Rate limit exceeded Fix: Wait, reduce request frequency</p>"},{"location":"v2/troubleshooting/#docker-errors","title":"Docker Errors","text":"<p><pre><code>port is already allocated\n</code></pre> Cause: Port conflict Fix: Stop conflicting service, change port in docker-compose.yml</p> <p><pre><code>no space left on device\n</code></pre> Cause: Disk full Fix: Clean up: <code>docker system prune -a</code></p> <p><pre><code>network not found\n</code></pre> Cause: Docker network missing Fix: Recreate: <code>docker compose down && docker compose up -d</code></p>"},{"location":"v2/troubleshooting/#when-to-get-help","title":"When to Get Help","text":"<p>Escalate to GitHub issues if:</p> <ul> <li>Error persists after troubleshooting</li> <li>Data corruption or loss</li> <li>Security vulnerability discovered</li> <li>Bug in core functionality</li> <li>Documentation unclear</li> </ul>"},{"location":"v2/troubleshooting/#related-documentation","title":"Related Documentation","text":"<ul> <li>Common Errors</li> <li>FAQ</li> <li>Docker Issues</li> <li>Database Issues</li> <li>Authentication Issues</li> <li>Email Issues</li> <li>Geocoding Issues</li> <li>Monitoring Issues</li> <li>Performance Optimization</li> <li>Development Guide</li> <li>Deployment Guide</li> </ul>"},{"location":"v2/troubleshooting/auth-issues/","title":"Authentication and Authorization Issues","text":"<p>This guide covers authentication (who you are) and authorization (what you can do) problems in Changemaker Lite V2.</p>"},{"location":"v2/troubleshooting/auth-issues/#overview","title":"Overview","text":""},{"location":"v2/troubleshooting/auth-issues/#authentication-system","title":"Authentication System","text":"<p>Changemaker Lite V2 uses JWT-based authentication:</p> <ul> <li>Access tokens - Short-lived (15 minutes), stored in memory</li> <li>Refresh tokens - Long-lived (7 days), stored in database</li> <li>bcrypt passwords - Hashed with salt (10 rounds)</li> <li>Token rotation - New refresh token on each refresh</li> </ul>"},{"location":"v2/troubleshooting/auth-issues/#authorization-system","title":"Authorization System","text":"<p>Role-based access control (RBAC) with 5 roles:</p> Role Level Permissions <code>SUPER_ADMIN</code> 5 Full access to everything <code>INFLUENCE_ADMIN</code> 4 Manage campaigns, responses, email queue <code>MAP_ADMIN</code> 3 Manage locations, cuts, shifts, canvass <code>USER</code> 2 View public content, canvass (if assigned shift) <code>TEMP</code> 1 Very limited - shift signup confirmation only"},{"location":"v2/troubleshooting/auth-issues/#security-features","title":"Security Features","text":"<ul> <li>Password policy - 12+ chars, uppercase, lowercase, digit</li> <li>User enumeration prevention - Generic error messages</li> <li>Rate limiting - 10 requests/min on auth endpoints</li> <li>Refresh token rotation - Atomic transaction prevents race conditions</li> <li>Redis authentication - Required password for Redis connection</li> </ul>"},{"location":"v2/troubleshooting/auth-issues/#login-failures","title":"Login Failures","text":""},{"location":"v2/troubleshooting/auth-issues/#invalid-credentials","title":"Invalid Credentials","text":"<p>Severity: \ud83d\udfe1 Medium</p>"},{"location":"v2/troubleshooting/auth-issues/#symptoms","title":"Symptoms","text":"<pre><code>{\n \"error\": \"Unauthorized\",\n \"message\": \"Invalid credentials\"\n}\n</code></pre> <p>Same message for: - User not found - Wrong password - User suspended</p> <p>This is intentional (prevents user enumeration).</p>"},{"location":"v2/troubleshooting/auth-issues/#common-causes","title":"Common Causes","text":"<ol> <li>Wrong password - Password incorrect</li> <li>User doesn't exist - Email not registered</li> <li>Typo in email - Email address wrong</li> <li>Account suspended - User marked as suspended</li> </ol>"},{"location":"v2/troubleshooting/auth-issues/#solutions","title":"Solutions","text":"<p>Solution 1: Verify user exists</p> <pre><code># 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</code></pre> <p>Solution 2: Reset password</p> <pre><code># 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</code></pre> <p>Solution 3: Create missing user</p> <pre><code># 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</code></pre> <p>Solution 4: Check for suspended account</p> <pre><code># 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</code></pre> <p>Solution 5: Check password requirements</p> <p>Password must meet requirements: - 12+ characters - At least 1 uppercase letter - At least 1 lowercase letter - At least 1 digit</p> <pre><code># Valid examples:\nSecurePass123!\nMyP@ssword99\nAdmin12345678\n</code></pre>"},{"location":"v2/troubleshooting/auth-issues/#prevention","title":"Prevention","text":"<ul> <li>Password manager - Use password manager to avoid typos</li> <li>Password reset flow - Implement forgot password feature</li> <li>Clear requirements - Display password requirements on register</li> <li>User-friendly errors - Guide users without revealing if email exists</li> </ul> <p>User Enumeration</p> <p>The same error message for \"user not found\" and \"wrong password\" is intentional security behavior to prevent attackers from discovering valid email addresses.</p>"},{"location":"v2/troubleshooting/auth-issues/#account-suspended","title":"Account Suspended","text":"<p>Severity: \ud83d\udfe0 High</p>"},{"location":"v2/troubleshooting/auth-issues/#symptoms_1","title":"Symptoms","text":"<pre><code>{\n \"error\": \"Forbidden\",\n \"message\": \"Account suspended\"\n}\n</code></pre> <p>User can't log in even with correct credentials.</p>"},{"location":"v2/troubleshooting/auth-issues/#common-causes_1","title":"Common Causes","text":"<ol> <li>Manual suspension - Admin suspended account</li> <li>Security violation - Account flagged for suspicious activity</li> <li>Terms violation - Account suspended for policy violation</li> </ol>"},{"location":"v2/troubleshooting/auth-issues/#solutions_1","title":"Solutions","text":"<p>Solution 1: Check suspension status</p> <pre><code># 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</code></pre> <p>Solution 2: Unsuspend account</p> <pre><code># 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</code></pre> <p>Solution 3: Contact administrator</p> <p>If you're a user: 1. Contact system administrator 2. Provide your email address 3. Wait for account review</p>"},{"location":"v2/troubleshooting/auth-issues/#prevention_1","title":"Prevention","text":"<ul> <li>Suspension policy - Clear policy on suspension reasons</li> <li>Appeal process - Allow users to appeal suspensions</li> <li>Audit trail - Log suspension reasons and who suspended</li> </ul> <p>V2 Status</p> <p>V2 doesn't currently have a suspended field. This section is for future implementation or if added via custom migration.</p>"},{"location":"v2/troubleshooting/auth-issues/#email-not-verified","title":"Email Not Verified","text":"<p>Severity: \ud83d\udfe2 Low</p>"},{"location":"v2/troubleshooting/auth-issues/#symptoms_2","title":"Symptoms","text":"<pre><code>{\n \"error\": \"Forbidden\",\n \"message\": \"Email not verified. Please check your email for verification link.\"\n}\n</code></pre>"},{"location":"v2/troubleshooting/auth-issues/#common-causes_2","title":"Common Causes","text":"<ol> <li>Email not verified - User didn't click verification link</li> <li>Verification email not received - Email went to spam</li> <li>Verification link expired - Link older than 24 hours</li> </ol>"},{"location":"v2/troubleshooting/auth-issues/#solutions_2","title":"Solutions","text":"<p>Solution 1: Check verification status</p> <pre><code># 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</code></pre> <p>Solution 2: Manually verify email</p> <pre><code># 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</code></pre> <p>Solution 3: Resend verification email</p> <pre><code># 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</code></pre> <p>Solution 4: Check spam folder</p> <p>Verification emails may be marked as spam. Check: 1. Spam/Junk folder 2. Promotions tab (Gmail) 3. Email filters</p>"},{"location":"v2/troubleshooting/auth-issues/#prevention_2","title":"Prevention","text":"<ul> <li>Clear instructions - Tell users to check spam</li> <li>From address - Use recognizable from address</li> <li>SPF/DKIM/DMARC - Configure email authentication</li> <li>Resend option - Easy way to resend verification</li> </ul> <p>V2 Status</p> <p>V2 doesn't currently require email verification for login. This section is for future implementation.</p>"},{"location":"v2/troubleshooting/auth-issues/#token-issues","title":"Token Issues","text":""},{"location":"v2/troubleshooting/auth-issues/#token-expired","title":"Token Expired","text":"<p>Severity: \ud83d\udfe1 Medium</p>"},{"location":"v2/troubleshooting/auth-issues/#symptoms_3","title":"Symptoms","text":"<pre><code>{\n \"error\": \"Unauthorized\",\n \"message\": \"Token expired\"\n}\n</code></pre> <p>Or:</p> <pre><code>Error: jwt expired\n</code></pre>"},{"location":"v2/troubleshooting/auth-issues/#common-causes_3","title":"Common Causes","text":"<ol> <li>Access token expired - Normal after 15 minutes inactive</li> <li>Refresh token expired - After 7 days</li> <li>System clock skew - Server/client time difference</li> </ol>"},{"location":"v2/troubleshooting/auth-issues/#solutions_3","title":"Solutions","text":"<p>Solution 1: Frontend auto-refresh</p> <p>Frontend automatically refreshes tokens on 401. If this fails:</p> <pre><code>// 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</code></pre> <p>Solution 2: Manual refresh</p> <pre><code># 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</code></pre> <p>Solution 3: Check token expiration</p> <pre><code>// 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</code></pre> <p>Solution 4: Check system time</p> <pre><code># 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</code></pre> <p>Solution 5: Log in again</p> <p>If refresh token also expired:</p> <ol> <li>You'll be redirected to login automatically</li> <li>Log in with email/password</li> <li>New tokens issued</li> </ol>"},{"location":"v2/troubleshooting/auth-issues/#prevention_3","title":"Prevention","text":"<ul> <li>Sliding sessions - Auto-refresh extends session</li> <li>Long refresh window - 7-day refresh token validity</li> <li>Activity tracking - Keep track of last activity</li> <li>Clock sync - Keep server time accurate</li> </ul>"},{"location":"v2/troubleshooting/auth-issues/#invalid-token","title":"Invalid Token","text":"<p>Severity: \ud83d\udfe0 High</p>"},{"location":"v2/troubleshooting/auth-issues/#symptoms_4","title":"Symptoms","text":"<pre><code>{\n \"error\": \"Unauthorized\",\n \"message\": \"Invalid token\"\n}\n</code></pre> <p>Or:</p> <pre><code>Error: jwt malformed\nError: invalid signature\nError: invalid algorithm\n</code></pre>"},{"location":"v2/troubleshooting/auth-issues/#common-causes_4","title":"Common Causes","text":"<ol> <li>Corrupted token - LocalStorage corruption</li> <li>Wrong secret - JWT_ACCESS_SECRET changed</li> <li>Tampered token - Someone modified the token</li> <li>Format error - Not a valid JWT</li> </ol>"},{"location":"v2/troubleshooting/auth-issues/#solutions_4","title":"Solutions","text":"<p>Solution 1: Verify JWT format</p> <p>Valid JWT has 3 parts separated by dots:</p> <pre><code>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</code></pre> <p>Solution 2: Clear localStorage and re-login</p> <pre><code>// In browser console\nlocalStorage.clear();\n// Then log in again\n</code></pre> <p>Solution 3: Verify JWT_ACCESS_SECRET</p> <pre><code># 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</code></pre> <p>Solution 4: Test token verification</p> <pre><code># 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</code></pre> <p>Solution 5: Check API logs</p> <pre><code># 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</code></pre>"},{"location":"v2/troubleshooting/auth-issues/#prevention_4","title":"Prevention","text":"<ul> <li>Secure secrets - Use strong, random secrets</li> <li>Never change secrets - Changing logs out all users</li> <li>Don't expose secrets - Never commit to git</li> <li>Token validation - Validate before using</li> </ul>"},{"location":"v2/troubleshooting/auth-issues/#token-not-found-in-header","title":"Token Not Found in Header","text":"<p>Severity: \ud83d\udfe1 Medium</p>"},{"location":"v2/troubleshooting/auth-issues/#symptoms_5","title":"Symptoms","text":"<pre><code>{\n \"error\": \"Unauthorized\",\n \"message\": \"No token provided\"\n}\n</code></pre> <p>Or:</p> <pre><code>{\n \"error\": \"Unauthorized\",\n \"message\": \"Invalid authorization header format\"\n}\n</code></pre>"},{"location":"v2/troubleshooting/auth-issues/#common-causes_5","title":"Common Causes","text":"<ol> <li>Missing Authorization header - Header not sent</li> <li>Wrong header format - Not \"Bearer token\"</li> <li>Token not in localStorage - User not logged in</li> <li>API client misconfigured - axios interceptor not working</li> </ol>"},{"location":"v2/troubleshooting/auth-issues/#solutions_5","title":"Solutions","text":"<p>Solution 1: Check if logged in</p> <pre><code>// 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</code></pre> <p>Solution 2: Verify header format</p> <pre><code># 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</code></pre> <p>Solution 3: Check axios interceptor</p> <p>In <code>admin/src/lib/api.ts</code>:</p> <pre><code>// 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</code></pre> <p>Solution 4: Test with curl</p> <pre><code># 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</code></pre> <p>Solution 5: Log in again</p> <pre><code># If all else fails, log out and log in\nlocalStorage.clear();\n# Navigate to /login\n</code></pre>"},{"location":"v2/troubleshooting/auth-issues/#prevention_5","title":"Prevention","text":"<ul> <li>axios interceptor - Automatically add token to requests</li> <li>Error handling - Redirect to login on 401</li> <li>Token persistence - Store token in localStorage</li> <li>Testing - Test auth flow regularly</li> </ul>"},{"location":"v2/troubleshooting/auth-issues/#refresh-token-invalid","title":"Refresh Token Invalid","text":"<p>Severity: \ud83d\udfe0 High</p>"},{"location":"v2/troubleshooting/auth-issues/#symptoms_6","title":"Symptoms","text":"<pre><code>{\n \"error\": \"Unauthorized\",\n \"message\": \"Invalid refresh token\"\n}\n</code></pre> <p>Auto-refresh fails, user logged out.</p>"},{"location":"v2/troubleshooting/auth-issues/#common-causes_6","title":"Common Causes","text":"<ol> <li>Refresh token expired - Older than 7 days</li> <li>Token revoked - User logged out explicitly</li> <li>Token not in database - Database was reset</li> <li>Token rotation - Token already used (consumed)</li> </ol>"},{"location":"v2/troubleshooting/auth-issues/#solutions_6","title":"Solutions","text":"<p>Solution 1: Check refresh token in database</p> <pre><code># 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</code></pre> <p>Solution 2: Check expiration</p> <pre><code># 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</code></pre> <p>Solution 3: Log in again</p> <p>Refresh token can't be renewed. Must log in with email/password:</p> <pre><code>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</code></pre> <p>Solution 4: Clear old refresh tokens</p> <pre><code># 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</code></pre>"},{"location":"v2/troubleshooting/auth-issues/#prevention_6","title":"Prevention","text":"<ul> <li>Long expiration - 7-day refresh token validity</li> <li>Token rotation - New refresh token on each refresh</li> <li>Cleanup job - Delete expired tokens periodically</li> <li>Multi-device support - Multiple refresh tokens per user</li> </ul>"},{"location":"v2/troubleshooting/auth-issues/#permission-errors","title":"Permission Errors","text":""},{"location":"v2/troubleshooting/auth-issues/#insufficient-permissions","title":"Insufficient Permissions","text":"<p>Severity: \ud83d\udfe1 Medium</p>"},{"location":"v2/troubleshooting/auth-issues/#symptoms_7","title":"Symptoms","text":"<pre><code>{\n \"error\": \"Forbidden\",\n \"message\": \"Insufficient permissions\"\n}\n</code></pre> <p>Or role-specific:</p> <pre><code>{\n \"error\": \"Forbidden\",\n \"message\": \"Requires one of: SUPER_ADMIN, MAP_ADMIN\"\n}\n</code></pre>"},{"location":"v2/troubleshooting/auth-issues/#common-causes_7","title":"Common Causes","text":"<ol> <li>Wrong role - User doesn't have required role</li> <li>TEMP user - Temporary user trying to access admin features</li> <li>Feature disabled - Feature flag not enabled</li> <li>Endpoint restricted - Endpoint requires specific role</li> </ol>"},{"location":"v2/troubleshooting/auth-issues/#solutions_7","title":"Solutions","text":"<p>Solution 1: Check user role</p> <pre><code># 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</code></pre> <p>Solution 2: Update user role</p> <pre><code># 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</code></pre> <p>Solution 3: Check endpoint requirements</p> <p>In API code (<code>api/src/modules/*/routes.ts</code>):</p> <pre><code>// 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</code></pre> <p>Solution 4: Verify feature flags</p> <pre><code># Check .env for feature flags\ncat .env | grep ENABLE\n\n# Example:\nENABLE_MEDIA_FEATURES=true\nLISTMONK_SYNC_ENABLED=true\n</code></pre> <p>Solution 5: Check TEMP user restrictions</p> <p>TEMP users are created during shift signup and have very limited permissions:</p> <pre><code>// TEMP users blocked by requireNonTemp middleware\nrouter.get('/my-data',\n authenticate,\n requireNonTemp, // Blocks TEMP users\n controller.getData\n);\n</code></pre> <p>To convert TEMP to USER:</p> <pre><code>docker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n -c \"UPDATE \\\"User\\\" SET role = 'USER' WHERE email = 'temp@example.com';\"\n</code></pre>"},{"location":"v2/troubleshooting/auth-issues/#prevention_7","title":"Prevention","text":"<ul> <li>Clear role descriptions - Document what each role can do</li> <li>Role matrix - Table showing role \u2192 permission mapping</li> <li>Upgrade flow - Easy way for users to upgrade from TEMP to USER</li> <li>Helpful errors - Show which role is required</li> </ul>"},{"location":"v2/troubleshooting/auth-issues/#role-restrictions","title":"Role Restrictions","text":"<p>Severity: \ud83d\udfe2 Low</p>"},{"location":"v2/troubleshooting/auth-issues/#symptoms_8","title":"Symptoms","text":"<p>User logged in but can't access certain features.</p>"},{"location":"v2/troubleshooting/auth-issues/#common-causes_8","title":"Common Causes","text":"<ol> <li>Not enough permissions - Role too low for feature</li> <li>Feature flag - Feature not enabled</li> <li>TEMP user - Temporary account with restrictions</li> </ol>"},{"location":"v2/troubleshooting/auth-issues/#solutions_8","title":"Solutions","text":"<p>Solution 1: View role permissions</p> Feature SUPER_ADMIN INFLUENCE_ADMIN MAP_ADMIN USER TEMP User management \u2705 \u274c \u274c \u274c \u274c Settings \u2705 \u274c \u274c \u274c \u274c Campaigns \u2705 \u2705 \u274c \u274c \u274c Responses \u2705 \u2705 \u274c \u274c \u274c Email queue \u2705 \u2705 \u274c \u274c \u274c Locations \u2705 \u274c \u2705 \u274c \u274c Cuts \u2705 \u274c \u2705 \u274c \u274c Shifts \u2705 \u274c \u2705 \u274c \u274c Canvass dashboard \u2705 \u274c \u2705 \u274c \u274c Public campaigns \u2705 \u2705 \u2705 \u2705 \u274c Public shifts \u2705 \u2705 \u2705 \u2705 \u2705 Volunteer canvass \u2705 \u2705 \u2705 \u2705 \u274c <p>Solution 2: Request role upgrade</p> <p>If you need higher permissions: 1. Contact system administrator 2. Explain why you need the role 3. Wait for approval and role change</p> <p>Solution 3: Create admin account</p> <p>For first admin (if none exist):</p> <pre><code># 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</code></pre>"},{"location":"v2/troubleshooting/auth-issues/#prevention_8","title":"Prevention","text":"<ul> <li>Document roles - Clear description of each role</li> <li>Role request process - Easy way to request role upgrade</li> <li>Audit trail - Log role changes</li> <li>Principle of least privilege - Give minimum role needed</li> </ul>"},{"location":"v2/troubleshooting/auth-issues/#session-issues","title":"Session Issues","text":""},{"location":"v2/troubleshooting/auth-issues/#session-timeout","title":"Session Timeout","text":"<p>Severity: \ud83d\udfe2 Low</p>"},{"location":"v2/troubleshooting/auth-issues/#symptoms_9","title":"Symptoms","text":"<p>User inactive for a while, then gets logged out.</p>"},{"location":"v2/troubleshooting/auth-issues/#current-behavior","title":"Current Behavior","text":"<p>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</p>"},{"location":"v2/troubleshooting/auth-issues/#solutions_9","title":"Solutions","text":"<p>Solution 1: Configure token expiration</p> <p>In <code>.env</code>:</p> <pre><code># Access token (default: 15m)\nJWT_ACCESS_EXPIRATION=30m\n\n# Refresh token (default: 7d)\nJWT_REFRESH_EXPIRATION=14d\n</code></pre> <p>Restart API:</p> <pre><code>docker compose restart api\n</code></pre> <p>Solution 2: Implement activity tracking</p> <pre><code>// 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</code></pre>"},{"location":"v2/troubleshooting/auth-issues/#prevention_9","title":"Prevention","text":"<ul> <li>Sliding sessions - Auto-refresh extends session</li> <li>Long refresh window - 7-day default</li> <li>Activity tracking - Reset timeout on activity</li> <li>Warning before logout - Show countdown before timeout</li> </ul>"},{"location":"v2/troubleshooting/auth-issues/#multiple-device-conflicts","title":"Multiple Device Conflicts","text":"<p>Severity: \ud83d\udfe2 Low</p>"},{"location":"v2/troubleshooting/auth-issues/#symptoms_10","title":"Symptoms","text":"<p>User logged in on multiple devices, behavior inconsistent.</p>"},{"location":"v2/troubleshooting/auth-issues/#current-behavior_1","title":"Current Behavior","text":"<p>V2 supports multiple devices: - Each login creates new refresh token - All devices stay logged in independently - No device limit by default</p>"},{"location":"v2/troubleshooting/auth-issues/#solutions_10","title":"Solutions","text":"<p>Solution 1: View user's devices</p> <pre><code># 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</code></pre> <p>Solution 2: Log out all devices</p> <pre><code># 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</code></pre> <p>Solution 3: Log out specific device</p> <pre><code># 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</code></pre> <p>Solution 4: Implement device limit</p> <p>In <code>api/src/modules/auth/auth.service.ts</code>:</p> <pre><code>// 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</code></pre>"},{"location":"v2/troubleshooting/auth-issues/#prevention_10","title":"Prevention","text":"<ul> <li>Device management UI - Show logged-in devices</li> <li>Device limit - Max 5-10 devices per user</li> <li>Device naming - Let users name their devices</li> <li>Remote logout - Let users log out other devices</li> </ul>"},{"location":"v2/troubleshooting/auth-issues/#password-reset-issues","title":"Password Reset Issues","text":""},{"location":"v2/troubleshooting/auth-issues/#reset-link-expired","title":"Reset Link Expired","text":"<p>Severity: \ud83d\udfe2 Low</p>"},{"location":"v2/troubleshooting/auth-issues/#symptoms_11","title":"Symptoms","text":"<pre><code>{\n \"error\": \"Bad Request\",\n \"message\": \"Password reset link expired or invalid\"\n}\n</code></pre>"},{"location":"v2/troubleshooting/auth-issues/#common-causes_9","title":"Common Causes","text":"<ol> <li>Link expired - Older than 24 hours</li> <li>Already used - Link can only be used once</li> <li>Wrong token - Token doesn't match database</li> </ol>"},{"location":"v2/troubleshooting/auth-issues/#solutions_11","title":"Solutions","text":"<p>Solution 1: Request new reset link</p> <pre><code># 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</code></pre> <p>Solution 2: Manually reset password</p> <pre><code># 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</code></pre>"},{"location":"v2/troubleshooting/auth-issues/#prevention_11","title":"Prevention","text":"<ul> <li>Longer expiration - 24-hour expiration is reasonable</li> <li>Clear messaging - Tell users link expires</li> <li>Easy re-request - Simple way to request new link</li> </ul> <p>V2 Status</p> <p>V2 doesn't currently have password reset flow. This section is for future implementation.</p>"},{"location":"v2/troubleshooting/auth-issues/#email-not-received","title":"Email Not Received","text":"<p>Severity: \ud83d\udfe1 Medium</p>"},{"location":"v2/troubleshooting/auth-issues/#symptoms_12","title":"Symptoms","text":"<p>User requests password reset but doesn't receive email.</p>"},{"location":"v2/troubleshooting/auth-issues/#common-causes_10","title":"Common Causes","text":"<ol> <li>Email in spam - Filtered to spam folder</li> <li>SMTP issue - Email sending failed</li> <li>Wrong email - Typo in email address</li> <li>Email delay - Taking long to deliver</li> </ol>"},{"location":"v2/troubleshooting/auth-issues/#solutions_12","title":"Solutions","text":"<p>Solution 1: Check spam folder</p> <ol> <li>Check Spam/Junk folder</li> <li>Check Promotions tab (Gmail)</li> <li>Check email filters</li> </ol> <p>Solution 2: Check email logs</p> <pre><code># 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</code></pre> <p>Solution 3: Check MailHog (dev mode)</p> <p>If <code>EMAIL_TEST_MODE=true</code>:</p> <pre><code># Open MailHog\nhttp://localhost:8025\n\n# All emails appear here instead of being sent\n</code></pre> <p>Solution 4: Test SMTP connection</p> <pre><code># 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</code></pre> <p>Solution 5: Manually reset password</p> <p>See \"Manually reset password\" in previous section.</p>"},{"location":"v2/troubleshooting/auth-issues/#prevention_12","title":"Prevention","text":"<ul> <li>Email testing - Test email delivery in production</li> <li>Clear from address - Use recognizable sender</li> <li>SPF/DKIM/DMARC - Configure email authentication</li> <li>Resend option - Easy way to resend email</li> </ul>"},{"location":"v2/troubleshooting/auth-issues/#rate-limiting","title":"Rate Limiting","text":""},{"location":"v2/troubleshooting/auth-issues/#too-many-login-attempts","title":"Too Many Login Attempts","text":"<p>Severity: \ud83d\udfe1 Medium</p>"},{"location":"v2/troubleshooting/auth-issues/#symptoms_13","title":"Symptoms","text":"<pre><code>{\n \"error\": \"Too Many Requests\",\n \"message\": \"Too many login attempts. Please try again later.\"\n}\n</code></pre>"},{"location":"v2/troubleshooting/auth-issues/#common-causes_11","title":"Common Causes","text":"<ol> <li>Too many failed logins - More than 10/minute</li> <li>Automated attack - Bot trying to brute-force</li> <li>Shared IP - Multiple users behind same NAT</li> </ol>"},{"location":"v2/troubleshooting/auth-issues/#solutions_13","title":"Solutions","text":"<p>Solution 1: Wait and retry</p> <p>Rate limit is per IP address: - Limit: 10 requests per minute - Window: 1 minute - Action: Wait 1 minute, then try again</p> <p>Solution 2: Check Redis rate limit</p> <pre><code># 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</code></pre> <p>Solution 3: Clear rate limit (admin)</p> <pre><code># 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</code></pre> <p>Solution 4: Adjust rate limit</p> <p>In <code>api/src/middleware/rate-limit.ts</code>:</p> <pre><code>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</code></pre> <p>Solution 5: Use different IP</p> <p>If behind NAT with many users: 1. Use VPN 2. Use mobile network 3. Contact administrator to whitelist IP</p>"},{"location":"v2/troubleshooting/auth-issues/#prevention_13","title":"Prevention","text":"<ul> <li>Reasonable limits - 10/min is reasonable</li> <li>Per-account limit - Also limit by email (not just IP)</li> <li>CAPTCHA - Add CAPTCHA after 3 failed attempts</li> <li>Account lockout - Lock account after 10 failed attempts</li> </ul>"},{"location":"v2/troubleshooting/auth-issues/#account-temporarily-locked","title":"Account Temporarily Locked","text":"<p>Severity: \ud83d\udfe1 Medium</p>"},{"location":"v2/troubleshooting/auth-issues/#symptoms_14","title":"Symptoms","text":"<pre><code>{\n \"error\": \"Forbidden\",\n \"message\": \"Account temporarily locked due to too many failed login attempts. Please try again in 30 minutes.\"\n}\n</code></pre>"},{"location":"v2/troubleshooting/auth-issues/#solutions_14","title":"Solutions","text":"<p>Solution 1: Wait for unlock</p> <p>Accounts auto-unlock after lockout period (default: 30 minutes).</p> <p>Solution 2: Manually unlock (admin)</p> <pre><code># 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</code></pre> <p>Solution 3: Contact administrator</p> <p>If you're a user: 1. Contact system administrator 2. Verify your identity 3. Request account unlock</p>"},{"location":"v2/troubleshooting/auth-issues/#prevention_14","title":"Prevention","text":"<ul> <li>Reasonable threshold - 10 failed attempts is reasonable</li> <li>Automatic unlock - Auto-unlock after time period</li> <li>Email notification - Notify user of lockout</li> <li>Appeal process - Way to appeal false positive</li> </ul> <p>V2 Status</p> <p>V2 doesn't currently have account lockout. This section is for future implementation.</p>"},{"location":"v2/troubleshooting/auth-issues/#debugging-auth","title":"Debugging Auth","text":""},{"location":"v2/troubleshooting/auth-issues/#checking-jwt-payload","title":"Checking JWT Payload","text":"<p>Severity: \ud83d\udfe2 Low (informational)</p>"},{"location":"v2/troubleshooting/auth-issues/#how-to-decode-jwt","title":"How to Decode JWT","text":"<pre><code>// 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</code></pre>"},{"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":"<pre><code># 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</code></pre>"},{"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":"<pre><code># 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</code></pre>"},{"location":"v2/troubleshooting/auth-issues/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/troubleshooting/auth-issues/#authentication-documentation","title":"Authentication Documentation","text":"<ul> <li>Auth Issues - This guide</li> <li>API Reference - Auth endpoints</li> <li>Security Audit - Security improvements</li> </ul>"},{"location":"v2/troubleshooting/auth-issues/#other-troubleshooting","title":"Other Troubleshooting","text":"<ul> <li>Common Errors - General errors</li> <li>Database Issues - Database problems</li> <li>Email Issues - Email and password reset</li> </ul>"},{"location":"v2/troubleshooting/auth-issues/#security-resources","title":"Security Resources","text":"<ul> <li>OWASP Authentication Cheat Sheet</li> <li>JWT Best Practices</li> <li>bcrypt</li> </ul> <p>Last Updated: February 2026 Version: V2.0 Status: Complete</p>"},{"location":"v2/troubleshooting/common-errors/","title":"Common Errors and Solutions","text":"<p>This guide covers the most frequently encountered errors in Changemaker Lite V2 and their solutions.</p>"},{"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":"<ol> <li>Find your error - Use the error code or message to locate the section</li> <li>Diagnose - Read the symptoms and causes</li> <li>Apply solution - Follow step-by-step instructions</li> <li>Prevent recurrence - Implement preventive measures</li> </ol>"},{"location":"v2/troubleshooting/common-errors/#error-severity-levels","title":"Error Severity Levels","text":"Level Icon Meaning Action Critical \ud83d\udd34 System down or data at risk Fix immediately High \ud83d\udfe0 Feature unavailable Fix within hours Medium \ud83d\udfe1 Degraded performance Fix within days Low \ud83d\udfe2 Minor inconvenience Fix when convenient"},{"location":"v2/troubleshooting/common-errors/#quick-error-lookup","title":"Quick Error Lookup","text":"Error Code Category Page 401 Authentication Link 403 Authorization Link 404 Not Found Link 422 Validation Link 500 Server Error Link CORS Frontend Link ECONNREFUSED Database Link"},{"location":"v2/troubleshooting/common-errors/#authentication-errors","title":"Authentication Errors","text":""},{"location":"v2/troubleshooting/common-errors/#401-unauthorized","title":"401 Unauthorized","text":"<p>Severity: \ud83d\udfe0 High</p>"},{"location":"v2/troubleshooting/common-errors/#symptoms","title":"Symptoms","text":"<pre><code>{\n \"error\": \"Unauthorized\",\n \"message\": \"Invalid or missing token\"\n}\n</code></pre> <p>Browser console: <pre><code>Error: Request failed with status code 401\n</code></pre></p>"},{"location":"v2/troubleshooting/common-errors/#common-causes","title":"Common Causes","text":"<ol> <li>Missing token - No Authorization header sent</li> <li>Expired token - Access token older than 15 minutes</li> <li>Invalid token - Corrupted or tampered token</li> <li>Wrong environment - Token from dev used in production</li> </ol>"},{"location":"v2/troubleshooting/common-errors/#solutions","title":"Solutions","text":"<p>Solution 1: Check if logged in</p> <pre><code>// In browser console\nconsole.log(localStorage.getItem('auth-storage'));\n</code></pre> <p>If null or missing <code>accessToken</code>, you need to log in again.</p> <p>Solution 2: Refresh token</p> <p>The frontend automatically refreshes tokens. If this fails:</p> <ol> <li>Log out completely</li> <li>Clear localStorage: <code>localStorage.clear()</code></li> <li>Log in again</li> </ol> <p>Solution 3: Verify API configuration</p> <p>Check <code>admin/.env</code>:</p> <pre><code>VITE_API_URL=http://localhost:4000 # Must match actual API URL\n</code></pre> <p>Solution 4: Check token expiration</p> <pre><code>// 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</code></pre> <p>If expired, the refresh interceptor should handle this. If not working:</p> <pre><code># Check API logs\ndocker compose logs api | grep \"refresh\"\n</code></pre>"},{"location":"v2/troubleshooting/common-errors/#prevention","title":"Prevention","text":"<ul> <li>Auto-refresh works - Frontend handles token refresh automatically</li> <li>Long sessions - Refresh tokens valid for 7 days</li> <li>Activity-based - Tokens refresh on API calls</li> <li>Clear error handling - Frontend redirects to login on failure</li> </ul> <p>Security Note</p> <p>401 errors may return generic messages to prevent user enumeration. This is intentional security behavior.</p>"},{"location":"v2/troubleshooting/common-errors/#403-forbidden","title":"403 Forbidden","text":"<p>Severity: \ud83d\udfe0 High</p>"},{"location":"v2/troubleshooting/common-errors/#symptoms_1","title":"Symptoms","text":"<pre><code>{\n \"error\": \"Forbidden\",\n \"message\": \"Insufficient permissions\"\n}\n</code></pre> <p>Or role-specific: <pre><code>{\n \"error\": \"Forbidden\",\n \"message\": \"Requires one of: SUPER_ADMIN, MAP_ADMIN\"\n}\n</code></pre></p>"},{"location":"v2/troubleshooting/common-errors/#common-causes_1","title":"Common Causes","text":"<ol> <li>Wrong role - User lacks required role</li> <li>TEMP user - Temporary users restricted from most features</li> <li>Feature disabled - Feature flag not enabled</li> <li>Wrong endpoint - Using admin endpoint as public user</li> </ol>"},{"location":"v2/troubleshooting/common-errors/#solutions_1","title":"Solutions","text":"<p>Solution 1: Check user role</p> <pre><code># 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</code></pre> <p>Solution 2: Update user role</p> <pre><code>-- 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</code></pre> <p>Solution 3: Check feature flags</p> <pre><code># In API logs\ndocker compose logs api | grep \"ENABLE_\"\n\n# Check .env\ncat .env | grep ENABLE\n</code></pre> <p>Solution 4: Verify endpoint permissions</p> <p>Check <code>api/src/modules/*/routes.ts</code>:</p> <pre><code>// Admin endpoint\nrouter.post('/', authenticate, requireRole('SUPER_ADMIN'), ...);\n\n// Public endpoint (no auth)\nrouter.get('/public', ...);\n</code></pre>"},{"location":"v2/troubleshooting/common-errors/#prevention_1","title":"Prevention","text":"<ul> <li>Role-based access control - Clear role hierarchy</li> <li>Explicit permissions - Each endpoint lists required roles</li> <li>Audit trail - Track permission changes</li> <li>Documentation - Role matrix in Access Control</li> </ul>"},{"location":"v2/troubleshooting/common-errors/#invalid-token","title":"Invalid Token","text":"<p>Severity: \ud83d\udfe0 High</p>"},{"location":"v2/troubleshooting/common-errors/#symptoms_2","title":"Symptoms","text":"<pre><code>{\n \"error\": \"Unauthorized\",\n \"message\": \"Invalid token\"\n}\n</code></pre> <p>Or in API logs: <pre><code>Error: jwt malformed\nError: invalid signature\nError: jwt must be provided\n</code></pre></p>"},{"location":"v2/troubleshooting/common-errors/#common-causes_2","title":"Common Causes","text":"<ol> <li>Corrupted token - LocalStorage corruption</li> <li>Wrong secret - JWT_ACCESS_SECRET changed</li> <li>Modified token - Attempted tampering</li> <li>Format error - Not a valid JWT structure</li> </ol>"},{"location":"v2/troubleshooting/common-errors/#solutions_2","title":"Solutions","text":"<p>Solution 1: Clear and re-login</p> <pre><code>// In browser console\nlocalStorage.clear();\n// Then log in again\n</code></pre> <p>Solution 2: Verify JWT structure</p> <p>Valid JWT has 3 parts separated by dots:</p> <pre><code>const token = 'header.payload.signature';\nconsole.log(token.split('.').length); // Should be 3\n</code></pre> <p>Solution 3: Check secret configuration</p> <pre><code># 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</code></pre> <p>Solution 4: Verify token in logs</p> <pre><code># API logs show token validation errors\ndocker compose logs api | tail -100 | grep \"jwt\"\n</code></pre>"},{"location":"v2/troubleshooting/common-errors/#prevention_2","title":"Prevention","text":"<ul> <li>Secure secrets - Use <code>openssl rand -hex 32</code></li> <li>Never commit secrets - Keep in .env (gitignored)</li> <li>Rotate carefully - Changing secrets logs out all users</li> <li>Monitor errors - Alert on spike in invalid token errors</li> </ul>"},{"location":"v2/troubleshooting/common-errors/#token-expired","title":"Token Expired","text":"<p>Severity: \ud83d\udfe1 Medium</p>"},{"location":"v2/troubleshooting/common-errors/#symptoms_3","title":"Symptoms","text":"<pre><code>{\n \"error\": \"Unauthorized\",\n \"message\": \"Token expired\"\n}\n</code></pre> <p>Or: <pre><code>Error: jwt expired\n</code></pre></p>"},{"location":"v2/troubleshooting/common-errors/#common-causes_3","title":"Common Causes","text":"<ol> <li>Access token expired - Normal after 15 minutes of inactivity</li> <li>Refresh token expired - Refresh token older than 7 days</li> <li>System clock skew - Server/client time mismatch</li> <li>Refresh failed - Refresh token invalid or revoked</li> </ol>"},{"location":"v2/troubleshooting/common-errors/#solutions_3","title":"Solutions","text":"<p>Solution 1: Automatic refresh</p> <p>Frontend automatically refreshes tokens on 401. If this fails:</p> <pre><code>// Check refresh token in localStorage\nconst storage = JSON.parse(localStorage.getItem('auth-storage'));\nconsole.log('Has refresh token:', !!storage?.state?.refreshToken);\n</code></pre> <p>Solution 2: Manual login</p> <p>If refresh token expired (after 7 days):</p> <ol> <li>You'll be redirected to login automatically</li> <li>Log in with email/password</li> <li>New tokens issued</li> </ol> <p>Solution 3: Check system time</p> <pre><code># On server\ndate\n\n# Sync if incorrect\nsudo ntpdate -s time.nist.gov\n</code></pre> <p>Solution 4: Verify token expiration</p> <pre><code># 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</code></pre>"},{"location":"v2/troubleshooting/common-errors/#prevention_3","title":"Prevention","text":"<ul> <li>Sliding sessions - Tokens auto-refresh on activity</li> <li>Long refresh window - 7-day refresh token validity</li> <li>Graceful handling - Automatic re-login redirect</li> <li>Activity tracking - Monitor token refresh patterns</li> </ul> <p>Developer Tip</p> <p>During development, use longer token expiration in .env: <pre><code>JWT_ACCESS_EXPIRATION=1d # Instead of 15m\nJWT_REFRESH_EXPIRATION=30d # Instead of 7d\n</code></pre></p>"},{"location":"v2/troubleshooting/common-errors/#user-not-found","title":"User Not Found","text":"<p>Severity: \ud83d\udfe1 Medium</p>"},{"location":"v2/troubleshooting/common-errors/#symptoms_4","title":"Symptoms","text":"<pre><code>{\n \"error\": \"Unauthorized\",\n \"message\": \"Invalid credentials\"\n}\n</code></pre> <p>Note: Same message for both \"user not found\" and \"wrong password\" (security feature).</p>"},{"location":"v2/troubleshooting/common-errors/#common-causes_4","title":"Common Causes","text":"<ol> <li>Wrong email - Typo in email address</li> <li>User deleted - Account removed from database</li> <li>Wrong database - Connected to wrong environment</li> <li>Case sensitivity - Email stored differently</li> </ol>"},{"location":"v2/troubleshooting/common-errors/#solutions_4","title":"Solutions","text":"<p>Solution 1: Verify user exists</p> <pre><code># 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</code></pre> <p>Solution 2: Check email format</p> <p>Emails are stored lowercase:</p> <pre><code>-- Find user case-insensitive\nSELECT * FROM \"User\" WHERE LOWER(email) = LOWER('User@Example.com');\n</code></pre> <p>Solution 3: Create user if missing</p> <pre><code># 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</code></pre> <p>Solution 4: Check database connection</p> <pre><code># Verify correct database\ndocker compose exec api npx prisma db pull\n\n# Check DATABASE_URL in .env\ncat .env | grep DATABASE_URL\n</code></pre>"},{"location":"v2/troubleshooting/common-errors/#prevention_4","title":"Prevention","text":"<ul> <li>Email validation - Enforce valid email format</li> <li>Case normalization - Store emails lowercase</li> <li>Soft deletes - Consider flagging instead of deleting</li> <li>Audit trail - Log user deletions</li> </ul>"},{"location":"v2/troubleshooting/common-errors/#api-errors","title":"API Errors","text":""},{"location":"v2/troubleshooting/common-errors/#500-internal-server-error","title":"500 Internal Server Error","text":"<p>Severity: \ud83d\udd34 Critical</p>"},{"location":"v2/troubleshooting/common-errors/#symptoms_5","title":"Symptoms","text":"<pre><code>{\n \"error\": \"Internal Server Error\",\n \"message\": \"An unexpected error occurred\"\n}\n</code></pre> <p>Or frontend error: <pre><code>Error: Request failed with status code 500\n</code></pre></p>"},{"location":"v2/troubleshooting/common-errors/#common-causes_5","title":"Common Causes","text":"<ol> <li>Unhandled exception - Code threw unexpected error</li> <li>Database error - Query failed</li> <li>Missing environment variable - Required config missing</li> <li>Type error - Runtime type mismatch</li> </ol>"},{"location":"v2/troubleshooting/common-errors/#solutions_5","title":"Solutions","text":"<p>Solution 1: Check API logs</p> <pre><code># 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</code></pre> <p>Solution 2: Common error patterns</p> <pre><code>// 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</code></pre> <p>Solution 3: Restart API</p> <pre><code># Restart API container\ndocker compose restart api\n\n# Or rebuild if code changed\ndocker compose up -d --build api\n</code></pre> <p>Solution 4: Enable debug logging</p> <pre><code># In .env\nLOG_LEVEL=debug\n\n# Restart API\ndocker compose restart api\n\n# Check detailed logs\ndocker compose logs api\n</code></pre>"},{"location":"v2/troubleshooting/common-errors/#prevention_5","title":"Prevention","text":"<ul> <li>Error handling - Try/catch in all routes</li> <li>Input validation - Validate all inputs with Zod</li> <li>Type safety - Use TypeScript strictly</li> <li>Health checks - Monitor API health</li> <li>Alerting - Set up alerts for 500 errors</li> </ul> <p>Production Alert</p> <p>500 errors indicate bugs. Always investigate and fix root cause.</p>"},{"location":"v2/troubleshooting/common-errors/#400-bad-request","title":"400 Bad Request","text":"<p>Severity: \ud83d\udfe1 Medium</p>"},{"location":"v2/troubleshooting/common-errors/#symptoms_6","title":"Symptoms","text":"<pre><code>{\n \"error\": \"Bad Request\",\n \"message\": \"Invalid request format\"\n}\n</code></pre> <p>Or with validation details: <pre><code>{\n \"error\": \"Bad Request\",\n \"message\": \"Validation failed: 2 errors\"\n}\n</code></pre></p>"},{"location":"v2/troubleshooting/common-errors/#common-causes_6","title":"Common Causes","text":"<ol> <li>Invalid JSON - Malformed request body</li> <li>Wrong Content-Type - Missing or incorrect header</li> <li>Missing required field - Required parameter not sent</li> <li>Invalid data type - String sent for number field</li> </ol>"},{"location":"v2/troubleshooting/common-errors/#solutions_6","title":"Solutions","text":"<p>Solution 1: Check request format</p> <pre><code>// 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</code></pre> <p>Solution 2: Validate against schema</p> <p>Check API schema in <code>api/src/modules/*/schemas.ts</code>:</p> <pre><code>// 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</code></pre> <p>Solution 3: Check API logs for details</p> <pre><code># 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</code></pre> <p>Solution 4: Test with curl</p> <pre><code># 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</code></pre>"},{"location":"v2/troubleshooting/common-errors/#prevention_6","title":"Prevention","text":"<ul> <li>Client-side validation - Validate before sending</li> <li>TypeScript types - Use generated types from API</li> <li>Schema documentation - Document all endpoints</li> <li>Error messages - Clear validation error messages</li> </ul>"},{"location":"v2/troubleshooting/common-errors/#404-not-found","title":"404 Not Found","text":"<p>Severity: \ud83d\udfe2 Low to \ud83d\udfe1 Medium</p>"},{"location":"v2/troubleshooting/common-errors/#symptoms_7","title":"Symptoms","text":"<pre><code>{\n \"error\": \"Not Found\",\n \"message\": \"Resource not found\"\n}\n</code></pre> <p>Or specific: <pre><code>{\n \"error\": \"Not Found\",\n \"message\": \"Campaign not found\"\n}\n</code></pre></p>"},{"location":"v2/troubleshooting/common-errors/#common-causes_7","title":"Common Causes","text":"<ol> <li>Wrong ID - Resource doesn't exist</li> <li>Wrong URL - Typo in endpoint path</li> <li>Deleted resource - Resource was deleted</li> <li>Wrong HTTP method - GET instead of POST</li> </ol>"},{"location":"v2/troubleshooting/common-errors/#solutions_7","title":"Solutions","text":"<p>Solution 1: Verify resource exists</p> <pre><code># 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</code></pre> <p>Solution 2: Check URL format</p> <pre><code>// 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</code></pre> <p>Solution 3: Check route registration</p> <pre><code># 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</code></pre> <p>Solution 4: Test endpoint</p> <pre><code># 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</code></pre>"},{"location":"v2/troubleshooting/common-errors/#prevention_7","title":"Prevention","text":"<ul> <li>UUID validation - Validate ID format before querying</li> <li>Soft deletes - Flag as deleted instead of removing</li> <li>Resource existence checks - Verify before operations</li> <li>Clear error messages - Specify which resource not found</li> </ul>"},{"location":"v2/troubleshooting/common-errors/#422-unprocessable-entity","title":"422 Unprocessable Entity","text":"<p>Severity: \ud83d\udfe1 Medium</p>"},{"location":"v2/troubleshooting/common-errors/#symptoms_8","title":"Symptoms","text":"<pre><code>{\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</code></pre>"},{"location":"v2/troubleshooting/common-errors/#common-causes_8","title":"Common Causes","text":"<ol> <li>Business logic violation - Email already exists</li> <li>Data integrity - Foreign key doesn't exist</li> <li>Complex validation - Password requirements not met</li> <li>State conflict - Can't delete resource in use</li> </ol>"},{"location":"v2/troubleshooting/common-errors/#solutions_8","title":"Solutions","text":"<p>Solution 1: Read validation details</p> <p>The <code>details</code> field shows exactly what's wrong:</p> <pre><code>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</code></pre> <p>Solution 2: Check constraints</p> <pre><code># 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</code></pre> <p>Solution 3: Fix data</p> <p>Common fixes:</p> <pre><code>// 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</code></pre> <p>Solution 4: Check database schema</p> <pre><code># View constraints\ndocker compose exec api npx prisma studio\n# Navigate to model, see unique fields and relations\n</code></pre>"},{"location":"v2/troubleshooting/common-errors/#prevention_8","title":"Prevention","text":"<ul> <li>Client validation - Check constraints before submitting</li> <li>Clear requirements - Document validation rules</li> <li>Helpful messages - Explain how to fix</li> <li>Cascade deletes - Auto-delete dependents when safe</li> </ul>"},{"location":"v2/troubleshooting/common-errors/#database-errors","title":"Database Errors","text":""},{"location":"v2/troubleshooting/common-errors/#connection-refused","title":"Connection Refused","text":"<p>Severity: \ud83d\udd34 Critical</p>"},{"location":"v2/troubleshooting/common-errors/#symptoms_9","title":"Symptoms","text":"<pre><code>Error: connect ECONNREFUSED 127.0.0.1:5433\n</code></pre> <p>Or: <pre><code>Error: Can't reach database server at `v2-postgres:5432`\n</code></pre></p>"},{"location":"v2/troubleshooting/common-errors/#common-causes_9","title":"Common Causes","text":"<ol> <li>Database not running - Container stopped</li> <li>Wrong connection string - Incorrect host/port</li> <li>Network issue - Container can't reach database</li> <li>Port conflict - Port already in use</li> </ol>"},{"location":"v2/troubleshooting/common-errors/#solutions_9","title":"Solutions","text":"<p>Solution 1: Check database status</p> <pre><code># List running containers\ndocker compose ps\n\n# Database should show as \"running\"\n# If not:\ndocker compose up -d v2-postgres\n</code></pre> <p>Solution 2: Verify connection string</p> <pre><code># 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</code></pre> <p>Solution 3: Check database logs</p> <pre><code># 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</code></pre> <p>Solution 4: Test connection manually</p> <pre><code># 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</code></pre> <p>Solution 5: Restart database</p> <pre><code># 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</code></pre>"},{"location":"v2/troubleshooting/common-errors/#prevention_9","title":"Prevention","text":"<ul> <li>Health checks - Monitor database availability</li> <li>Auto-restart - Configure restart policy</li> <li>Connection pooling - Handle transient failures</li> <li>Alerting - Alert on connection failures</li> </ul>"},{"location":"v2/troubleshooting/common-errors/#too-many-connections","title":"Too Many Connections","text":"<p>Severity: \ud83d\udfe0 High</p>"},{"location":"v2/troubleshooting/common-errors/#symptoms_10","title":"Symptoms","text":"<pre><code>Error: too many connections for database \"changemaker_v2\"\n</code></pre> <p>Or: <pre><code>Error: Prepared statement \"prisma_xxx\" already exists\n</code></pre></p>"},{"location":"v2/troubleshooting/common-errors/#common-causes_10","title":"Common Causes","text":"<ol> <li>Connection leak - Connections not released</li> <li>Pool too small - Not enough connections for load</li> <li>Long-running queries - Blocking connections</li> <li>Multiple clients - Too many Prisma instances</li> </ol>"},{"location":"v2/troubleshooting/common-errors/#solutions_10","title":"Solutions","text":"<p>Solution 1: Check active connections</p> <pre><code># 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</code></pre> <p>Solution 2: Kill idle connections</p> <pre><code>-- 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</code></pre> <p>Solution 3: Adjust connection pool</p> <p>In <code>api/prisma/schema.prisma</code>:</p> <pre><code>datasource db {\n provider = \"postgresql\"\n url = env(\"DATABASE_URL\")\n // Add connection pool config\n // connectionLimit = 10 // Default\n}\n</code></pre> <p>Or via DATABASE_URL:</p> <pre><code>DATABASE_URL=\"postgresql://user:pass@host:5432/db?connection_limit=20\"\n</code></pre> <p>Solution 4: Restart API</p> <pre><code># 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</code></pre> <p>Solution 5: Increase PostgreSQL max connections</p> <p>In <code>docker-compose.yml</code>:</p> <pre><code>v2-postgres:\n # ...\n command: postgres -c max_connections=200\n</code></pre> <p>Then restart:</p> <pre><code>docker compose up -d v2-postgres\n</code></pre>"},{"location":"v2/troubleshooting/common-errors/#prevention_10","title":"Prevention","text":"<ul> <li>Proper cleanup - Always close Prisma clients</li> <li>Connection pooling - Use appropriate pool size</li> <li>Monitor connections - Alert on high usage</li> <li>Query optimization - Reduce long-running queries</li> </ul>"},{"location":"v2/troubleshooting/common-errors/#unique-constraint-violation","title":"Unique Constraint Violation","text":"<p>Severity: \ud83d\udfe1 Medium</p>"},{"location":"v2/troubleshooting/common-errors/#symptoms_11","title":"Symptoms","text":"<pre><code>Error: Unique constraint failed on the fields: (`email`)\n</code></pre> <p>Or: <pre><code>PrismaClientKnownRequestError:\nUnique constraint failed on the constraint: `User_email_key`\n</code></pre></p>"},{"location":"v2/troubleshooting/common-errors/#common-causes_11","title":"Common Causes","text":"<ol> <li>Duplicate email - User already exists</li> <li>Race condition - Two creates at same time</li> <li>Case sensitivity - Email differs only in case</li> <li>Retry logic - Request sent multiple times</li> </ol>"},{"location":"v2/troubleshooting/common-errors/#solutions_11","title":"Solutions","text":"<p>Solution 1: Check existing records</p> <pre><code># 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</code></pre> <p>Solution 2: Update instead of create</p> <pre><code>// 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</code></pre> <p>Solution 3: Handle error gracefully</p> <pre><code>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</code></pre> <p>Solution 4: Delete duplicate</p> <pre><code># 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</code></pre>"},{"location":"v2/troubleshooting/common-errors/#prevention_11","title":"Prevention","text":"<ul> <li>Check before create - Query first to check existence</li> <li>Use upsert - Update or create atomically</li> <li>Unique indexes - Database enforces uniqueness</li> <li>Case normalization - Store emails lowercase</li> </ul>"},{"location":"v2/troubleshooting/common-errors/#foreign-key-constraint","title":"Foreign Key Constraint","text":"<p>Severity: \ud83d\udfe1 Medium</p>"},{"location":"v2/troubleshooting/common-errors/#symptoms_12","title":"Symptoms","text":"<pre><code>Error: Foreign key constraint failed on the field: `campaignId`\n</code></pre> <p>Or: <pre><code>Error: An operation failed because it depends on one or more records that were required but not found. Record to update not found.\n</code></pre></p>"},{"location":"v2/troubleshooting/common-errors/#common-causes_12","title":"Common Causes","text":"<ol> <li>Parent doesn't exist - Referenced record missing</li> <li>Wrong ID - Typo in foreign key value</li> <li>Delete order - Trying to delete parent before children</li> <li>Null constraint - Foreign key required but null provided</li> </ol>"},{"location":"v2/troubleshooting/common-errors/#solutions_12","title":"Solutions","text":"<p>Solution 1: Verify parent exists</p> <pre><code># 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</code></pre> <p>Solution 2: Create parent first</p> <pre><code>// 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</code></pre> <p>Solution 3: Delete children first</p> <pre><code>// 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</code></pre> <p>Solution 4: Use transactions</p> <pre><code>// Ensure atomicity\nawait prisma.$transaction([\n prisma.campaignEmail.deleteMany({ where: { campaignId } }),\n prisma.campaign.delete({ where: { id: campaignId } })\n]);\n</code></pre>"},{"location":"v2/troubleshooting/common-errors/#prevention_12","title":"Prevention","text":"<ul> <li>Cascade deletes - Configure in schema where appropriate</li> <li>Soft deletes - Flag as deleted instead of removing</li> <li>Validation - Check foreign keys exist before creating</li> <li>Transactions - Use for multi-step operations</li> </ul>"},{"location":"v2/troubleshooting/common-errors/#frontend-errors","title":"Frontend Errors","text":""},{"location":"v2/troubleshooting/common-errors/#network-error","title":"Network Error","text":"<p>Severity: \ud83d\udfe0 High</p>"},{"location":"v2/troubleshooting/common-errors/#symptoms_13","title":"Symptoms","text":"<p>Browser console: <pre><code>Error: Network Error\n</code></pre></p> <p>Or: <pre><code>AxiosError: Request failed with status code undefined\n</code></pre></p> <p>User sees: API request fails, loading spinner never stops.</p>"},{"location":"v2/troubleshooting/common-errors/#common-causes_13","title":"Common Causes","text":"<ol> <li>API down - API container not running</li> <li>Wrong API URL - VITE_API_URL misconfigured</li> <li>CORS issue - Browser blocking request</li> <li>Network timeout - Request taking too long</li> </ol>"},{"location":"v2/troubleshooting/common-errors/#solutions_13","title":"Solutions","text":"<p>Solution 1: Check API status</p> <pre><code># 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</code></pre> <p>Solution 2: Verify API URL</p> <pre><code># 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</code></pre> <p>Solution 3: Check browser console</p> <p>Press F12, check:</p> <ul> <li>Network tab - Does request appear? What's the status?</li> <li>Console tab - Any CORS errors?</li> </ul> <p>Solution 4: Test from different client</p> <pre><code># From command line\ncurl http://localhost:4000/api/campaigns\n\n# If this works but browser doesn't, it's a CORS issue\n</code></pre>"},{"location":"v2/troubleshooting/common-errors/#prevention_13","title":"Prevention","text":"<ul> <li>Health checks - Monitor API availability</li> <li>Error boundaries - Catch and display network errors</li> <li>Retry logic - Auto-retry failed requests</li> <li>Offline detection - Detect and handle offline state</li> </ul>"},{"location":"v2/troubleshooting/common-errors/#cors-errors","title":"CORS Errors","text":"<p>Severity: \ud83d\udfe0 High</p>"},{"location":"v2/troubleshooting/common-errors/#symptoms_14","title":"Symptoms","text":"<p>Browser console: <pre><code>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</code></pre></p>"},{"location":"v2/troubleshooting/common-errors/#common-causes_14","title":"Common Causes","text":"<ol> <li>Missing CORS config - API not configured for CORS</li> <li>Wrong origin - Admin URL not in allowed origins</li> <li>Credentials flag - withCredentials set but not allowed</li> <li>Preflight failure - OPTIONS request failing</li> </ol>"},{"location":"v2/troubleshooting/common-errors/#solutions_14","title":"Solutions","text":"<p>Solution 1: Check API CORS configuration</p> <p>In <code>api/src/server.ts</code>:</p> <pre><code>app.use(cors({\n origin: process.env.CORS_ORIGINS?.split(',') || ['http://localhost:3000'],\n credentials: true\n}));\n</code></pre> <p>Solution 2: Verify CORS_ORIGINS</p> <pre><code># Check .env\ncat .env | grep CORS_ORIGINS\n\n# Should include admin URL:\nCORS_ORIGINS=http://localhost:3000,https://app.cmlite.org\n</code></pre> <p>Solution 3: Add origin temporarily</p> <p>For development:</p> <pre><code># In .env\nCORS_ORIGINS=* # Allow all origins (dev only!)\n\n# Restart API\ndocker compose restart api\n</code></pre> <p>Solution 4: Check preflight request</p> <p>In browser Network tab:</p> <ol> <li>Find OPTIONS request before actual request</li> <li>Check if it returns 200 OK</li> <li>Check response headers include:</li> <li>Access-Control-Allow-Origin</li> <li>Access-Control-Allow-Methods</li> <li>Access-Control-Allow-Headers</li> </ol>"},{"location":"v2/troubleshooting/common-errors/#prevention_14","title":"Prevention","text":"<ul> <li>Explicit origins - List all allowed origins</li> <li>Environment-based - Different origins per environment</li> <li>Credentials support - Enable if using cookies/auth</li> <li>Preflight caching - Cache OPTIONS responses</li> </ul> <p>Security Note</p> <p>Never use <code>CORS_ORIGINS=*</code> in production with credentials enabled.</p>"},{"location":"v2/troubleshooting/common-errors/#module-not-found","title":"Module Not Found","text":"<p>Severity: \ud83d\udfe1 Medium</p>"},{"location":"v2/troubleshooting/common-errors/#symptoms_15","title":"Symptoms","text":"<pre><code>Error: Cannot find module '@/components/MyComponent'\n</code></pre> <p>Or: <pre><code>Module not found: Can't resolve 'some-package'\n</code></pre></p>"},{"location":"v2/troubleshooting/common-errors/#common-causes_15","title":"Common Causes","text":"<ol> <li>Missing dependency - Package not installed</li> <li>Wrong import path - Typo in path</li> <li>Path alias issue - @ alias not configured</li> <li>Case sensitivity - Wrong case in filename</li> </ol>"},{"location":"v2/troubleshooting/common-errors/#solutions_15","title":"Solutions","text":"<p>Solution 1: Install missing package</p> <pre><code>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</code></pre> <p>Solution 2: Check import path</p> <pre><code>// 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</code></pre> <p>Solution 3: Verify path alias</p> <p>In <code>admin/vite.config.ts</code>:</p> <pre><code>export default defineConfig({\n resolve: {\n alias: {\n '@': path.resolve(__dirname, './src')\n }\n }\n});\n</code></pre> <p>In <code>admin/tsconfig.json</code>:</p> <pre><code>{\n \"compilerOptions\": {\n \"paths\": {\n \"@/*\": [\"./src/*\"]\n }\n }\n}\n</code></pre> <p>Solution 4: Clear cache and reinstall</p> <pre><code>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</code></pre>"},{"location":"v2/troubleshooting/common-errors/#prevention_15","title":"Prevention","text":"<ul> <li>Type checking - Use TypeScript for import validation</li> <li>IDE support - Configure path aliases in IDE</li> <li>Linting - Use ESLint with import plugin</li> <li>Documentation - Document custom path aliases</li> </ul>"},{"location":"v2/troubleshooting/common-errors/#hydration-errors","title":"Hydration Errors","text":"<p>Severity: \ud83d\udfe1 Medium</p>"},{"location":"v2/troubleshooting/common-errors/#symptoms_16","title":"Symptoms","text":"<p>Browser console: <pre><code>Warning: Text content did not match. Server: \"...\" Client: \"...\"\n</code></pre></p> <p>Or: <pre><code>Error: Hydration failed because the initial UI does not match what was\nrendered on the server.\n</code></pre></p>"},{"location":"v2/troubleshooting/common-errors/#common-causes_16","title":"Common Causes","text":"<ol> <li>Date formatting - Server/client timezone difference</li> <li>Random values - Using Math.random() or uuid</li> <li>localStorage - Reading from localStorage during render</li> <li>User agent - Checking window.navigator during SSR</li> <li>Third-party scripts - Injected by browser extensions</li> </ol>"},{"location":"v2/troubleshooting/common-errors/#solutions_16","title":"Solutions","text":"<p>Solution 1: Use useEffect for client-only code</p> <pre><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</code></pre> <p>Solution 2: Consistent date formatting</p> <pre><code>// 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</code></pre> <p>Solution 3: suppressHydrationWarning for known mismatches</p> <pre><code>// For values that intentionally differ (like timestamps)\n<time suppressHydrationWarning>\n {new Date().toISOString()}\n</time>\n</code></pre> <p>Solution 4: Check browser extensions</p> <p>Disable browser extensions temporarily to see if error persists.</p>"},{"location":"v2/troubleshooting/common-errors/#prevention_16","title":"Prevention","text":"<ul> <li>Avoid client-only APIs during render - Use useEffect</li> <li>Consistent formatting - Same format server and client</li> <li>Test without extensions - Regular testing</li> <li>React DevTools - Use to identify mismatches</li> </ul> <p>Changemaker Lite V2</p> <p>Current admin is CSR (Client-Side Rendered) only, so hydration errors shouldn't occur. This section is for future SSR/SSG implementations.</p>"},{"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":"<p>Severity: \ud83d\udfe1 Medium</p>"},{"location":"v2/troubleshooting/common-errors/#symptoms_17","title":"Symptoms","text":"<pre><code>{\n \"error\": \"Payload Too Large\",\n \"message\": \"File size exceeds maximum of 10485760 bytes\"\n}\n</code></pre> <p>Or browser: <pre><code>Request Entity Too Large\n</code></pre></p>"},{"location":"v2/troubleshooting/common-errors/#common-causes_17","title":"Common Causes","text":"<ol> <li>File exceeds limit - Video larger than 10GB</li> <li>Nginx limit - Reverse proxy blocking</li> <li>Wrong content type - Not multipart/form-data</li> <li>Network timeout - Upload taking too long</li> </ol>"},{"location":"v2/troubleshooting/common-errors/#solutions_17","title":"Solutions","text":"<p>Solution 1: Check file size</p> <pre><code>// 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</code></pre> <p>Solution 2: Increase limits</p> <p>In <code>api/src/modules/media/routes/upload.routes.ts</code>:</p> <pre><code>fastify.register(multipart, {\n limits: {\n fileSize: 10 * 1024 * 1024 * 1024 // 10GB\n }\n});\n</code></pre> <p>In <code>nginx/conf.d/api.conf</code>:</p> <pre><code>client_max_body_size 10G;\n</code></pre> <p>Solution 3: Use chunked upload</p> <p>For very large files, implement resumable upload:</p> <pre><code>// TODO: Implement chunked upload in Phase 15\n</code></pre> <p>Solution 4: Compress video</p> <pre><code># Before uploading, compress with ffmpeg\nffmpeg -i input.mp4 -c:v libx264 -crf 23 -c:a aac output.mp4\n</code></pre>"},{"location":"v2/troubleshooting/common-errors/#prevention_17","title":"Prevention","text":"<ul> <li>Client validation - Check size before upload</li> <li>Progress indicator - Show upload progress</li> <li>Compression - Compress large videos</li> <li>Chunked uploads - For files > 1GB</li> </ul>"},{"location":"v2/troubleshooting/common-errors/#invalid-file-type","title":"Invalid File Type","text":"<p>Severity: \ud83d\udfe2 Low</p>"},{"location":"v2/troubleshooting/common-errors/#symptoms_18","title":"Symptoms","text":"<pre><code>{\n \"error\": \"Bad Request\",\n \"message\": \"Invalid file type. Allowed: mp4, mov, avi, mkv, webm, m4v, flv\"\n}\n</code></pre>"},{"location":"v2/troubleshooting/common-errors/#common-causes_18","title":"Common Causes","text":"<ol> <li>Wrong extension - File has unsupported extension</li> <li>Missing extension - Filename has no extension</li> <li>Mismatched extension - Extension doesn't match content</li> <li>MIME type issue - Browser sends wrong MIME type</li> </ol>"},{"location":"v2/troubleshooting/common-errors/#solutions_18","title":"Solutions","text":"<p>Solution 1: Check supported formats</p> <p>Supported video formats:</p> <ul> <li>MP4 (.mp4)</li> <li>MOV (.mov)</li> <li>AVI (.avi)</li> <li>MKV (.mkv)</li> <li>WebM (.webm)</li> <li>M4V (.m4v)</li> <li>FLV (.flv)</li> </ul> <p>Solution 2: Convert video</p> <pre><code># Convert to MP4 (most compatible)\nffmpeg -i input.avi -c:v libx264 -c:a aac output.mp4\n</code></pre> <p>Solution 3: Check file extension</p> <pre><code>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</code></pre> <p>Solution 4: Verify with file command</p> <pre><code># Check actual file type\nfile video.mp4\n\n# Should show:\n# video.mp4: ISO Media, MP4 v2 [ISO 14496-14]\n</code></pre>"},{"location":"v2/troubleshooting/common-errors/#prevention_18","title":"Prevention","text":"<ul> <li>Client validation - Check extension before upload</li> <li>MIME type checking - Validate content type</li> <li>File magic numbers - Check file signature</li> <li>Clear documentation - List supported formats</li> </ul>"},{"location":"v2/troubleshooting/common-errors/#upload-timeout","title":"Upload Timeout","text":"<p>Severity: \ud83d\udfe1 Medium</p>"},{"location":"v2/troubleshooting/common-errors/#symptoms_19","title":"Symptoms","text":"<pre><code>Error: timeout of 30000ms exceeded\n</code></pre> <p>Or: <pre><code>504 Gateway Timeout\n</code></pre></p>"},{"location":"v2/troubleshooting/common-errors/#common-causes_19","title":"Common Causes","text":"<ol> <li>Slow network - Large file, slow connection</li> <li>Server timeout - Request timeout too short</li> <li>Processing delay - FFprobe taking too long</li> <li>Network interruption - Connection dropped</li> </ol>"},{"location":"v2/troubleshooting/common-errors/#solutions_19","title":"Solutions","text":"<p>Solution 1: Increase timeout</p> <p>In <code>admin/src/lib/media-api.ts</code>:</p> <pre><code>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</code></pre> <p>Solution 2: Check upload progress</p> <pre><code>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</code></pre> <p>Solution 3: Increase nginx timeout</p> <p>In <code>nginx/conf.d/api.conf</code>:</p> <pre><code>proxy_read_timeout 300s;\nproxy_connect_timeout 300s;\nproxy_send_timeout 300s;\n</code></pre> <p>Solution 4: Upload via chunks</p> <pre><code>// TODO: Implement chunked upload for large files\n</code></pre>"},{"location":"v2/troubleshooting/common-errors/#prevention_19","title":"Prevention","text":"<ul> <li>Progress indicator - Show upload progress</li> <li>Generous timeouts - Allow enough time for large files</li> <li>Retry logic - Auto-retry on network errors</li> <li>Chunked uploads - For files > 1GB</li> </ul>"},{"location":"v2/troubleshooting/common-errors/#email-errors","title":"Email Errors","text":""},{"location":"v2/troubleshooting/common-errors/#smtp-connection-failed","title":"SMTP Connection Failed","text":"<p>Severity: \ud83d\udd34 Critical</p>"},{"location":"v2/troubleshooting/common-errors/#symptoms_20","title":"Symptoms","text":"<p>API logs: <pre><code>Error: Connection timeout\nError: connect ECONNREFUSED 127.0.0.1:587\n</code></pre></p> <p>Or: <pre><code>Error: Invalid login: 535-5.7.8 Username and Password not accepted\n</code></pre></p>"},{"location":"v2/troubleshooting/common-errors/#common-causes_20","title":"Common Causes","text":"<ol> <li>SMTP server down - Mail server unreachable</li> <li>Wrong credentials - Invalid username/password</li> <li>Port blocked - Firewall blocking SMTP port</li> <li>TLS/SSL issue - Certificate validation failed</li> </ol>"},{"location":"v2/troubleshooting/common-errors/#solutions_20","title":"Solutions","text":"<p>Solution 1: Test SMTP connection</p> <pre><code># Test with telnet\ntelnet smtp.gmail.com 587\n\n# Should connect and show:\n# 220 smtp.gmail.com ESMTP...\n</code></pre> <p>Solution 2: Verify SMTP configuration</p> <pre><code># 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</code></pre> <p>Solution 3: Use test mode</p> <pre><code># 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</code></pre> <p>Solution 4: Check Gmail app password</p> <p>For Gmail:</p> <ol> <li>Enable 2-factor authentication</li> <li>Generate app password at https://myaccount.google.com/apppasswords</li> <li>Use app password (not regular password) in SMTP_PASS</li> </ol> <p>Solution 5: Test with curl</p> <pre><code># 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</code></pre>"},{"location":"v2/troubleshooting/common-errors/#prevention_20","title":"Prevention","text":"<ul> <li>Test mode for dev - Use MailHog locally</li> <li>Monitor SMTP health - Alert on connection failures</li> <li>Fallback providers - Configure backup SMTP server</li> <li>Queue system - BullMQ retries failed emails</li> </ul>"},{"location":"v2/troubleshooting/common-errors/#template-not-found","title":"Template Not Found","text":"<p>Severity: \ud83d\udfe0 High</p>"},{"location":"v2/troubleshooting/common-errors/#symptoms_21","title":"Symptoms","text":"<p>API logs: <pre><code>Error: Email template not found: campaign-email\n</code></pre></p> <p>Or: <pre><code>Error: ENOENT: no such file or directory, open 'templates/campaign-email.html'\n</code></pre></p>"},{"location":"v2/troubleshooting/common-errors/#common-causes_21","title":"Common Causes","text":"<ol> <li>Missing template file - Template not created</li> <li>Wrong template name - Typo in template name</li> <li>Wrong path - Looking in wrong directory</li> <li>Deleted template - Template was removed</li> </ol>"},{"location":"v2/troubleshooting/common-errors/#solutions_21","title":"Solutions","text":"<p>Solution 1: Check template exists</p> <pre><code># 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</code></pre> <p>Solution 2: Verify template name</p> <p>In <code>api/src/services/email.service.ts</code>:</p> <pre><code>// 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</code></pre> <p>Solution 3: Create missing template</p> <pre><code># 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</code></pre> <p>Solution 4: Use email template system</p> <pre><code># Navigate to admin UI\nhttp://localhost:3000/app/email-templates\n\n# Create template there (saved to database + file)\n</code></pre>"},{"location":"v2/troubleshooting/common-errors/#prevention_21","title":"Prevention","text":"<ul> <li>Seed templates - Include in database seed</li> <li>Template management - Use admin UI to manage</li> <li>Version control - Keep templates in git</li> <li>Validation - Check template exists before sending</li> </ul>"},{"location":"v2/troubleshooting/common-errors/#variable-missing","title":"Variable Missing","text":"<p>Severity: \ud83d\udfe1 Medium</p>"},{"location":"v2/troubleshooting/common-errors/#symptoms_22","title":"Symptoms","text":"<p>Email received with placeholders not replaced: <pre><code>Hello {{name}},\nYour campaign {{campaignName}} is ready.\n</code></pre></p> <p>Or API logs: <pre><code>Warning: Template variable 'campaignName' not provided\n</code></pre></p>"},{"location":"v2/troubleshooting/common-errors/#common-causes_22","title":"Common Causes","text":"<ol> <li>Variable not passed - Missing from variables object</li> <li>Variable name mismatch - Typo in variable name</li> <li>Wrong template - Using wrong template</li> <li>Case sensitivity - Variable name case mismatch</li> </ol>"},{"location":"v2/troubleshooting/common-errors/#solutions_22","title":"Solutions","text":"<p>Solution 1: Check template variables</p> <p>In template file:</p> <pre><code><!-- templates/campaign-email.html -->\n<h1>Hello {{firstName}}</h1>\n<p>Your campaign \"{{campaignName}}\" is ready.</p>\n<p>Visit: {{campaignUrl}}</p>\n</code></pre> <p>Solution 2: Provide all variables</p> <pre><code>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</code></pre> <p>Solution 3: Use default values</p> <pre><code><!-- In template, provide fallback -->\n<h1>Hello {{firstName || 'Friend'}}</h1>\n</code></pre> <p>Solution 4: Validate before sending</p> <pre><code>// 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</code></pre>"},{"location":"v2/troubleshooting/common-errors/#prevention_22","title":"Prevention","text":"<ul> <li>Template validation - Check variables on save</li> <li>TypeScript types - Type template variables</li> <li>Documentation - Document required variables</li> <li>Default values - Provide sensible defaults</li> </ul>"},{"location":"v2/troubleshooting/common-errors/#quick-reference-table","title":"Quick Reference Table","text":"Error Code/Message Category Common Cause Quick Fix Severity 401 Unauthorized Auth Token expired Re-login \ud83d\udfe0 403 Forbidden Auth Wrong role Check user role \ud83d\udfe0 404 Not Found API Wrong URL/ID Verify resource exists \ud83d\udfe2 422 Unprocessable Validation Constraint violation Check validation details \ud83d\udfe1 500 Server Error API Code bug Check API logs \ud83d\udd34 ECONNREFUSED Database DB not running Start database \ud83d\udd34 Too many connections Database Connection leak Restart API \ud83d\udfe0 Unique constraint Database Duplicate record Use upsert or different value \ud83d\udfe1 Foreign key constraint Database Parent missing Create parent first \ud83d\udfe1 Network Error Frontend API down Check API status \ud83d\udfe0 CORS Error Frontend Origin not allowed Add to CORS_ORIGINS \ud83d\udfe0 Module not found Frontend Missing package npm install \ud83d\udfe1 File too large Upload Exceeds 10GB Compress or increase limit \ud83d\udfe1 Invalid file type Upload Wrong format Convert to MP4 \ud83d\udfe2 Upload timeout Upload Slow network Increase timeout \ud83d\udfe1 SMTP failed Email Wrong credentials Check SMTP config \ud83d\udd34 Template not found Email Missing file Create template \ud83d\udfe0 Variable missing Email Not provided Add to variables object \ud83d\udfe1"},{"location":"v2/troubleshooting/common-errors/#when-to-report-bugs","title":"When to Report Bugs","text":""},{"location":"v2/troubleshooting/common-errors/#report-these","title":"Report These","text":"<p>\u2705 Unexpected behavior - System does something wrong</p> <ul> <li>500 errors (unless caused by your config)</li> <li>Data corruption</li> <li>Security vulnerabilities</li> <li>Performance regressions</li> </ul> <p>\u2705 Missing features - Documented feature doesn't work</p> <ul> <li>API endpoint returns 404 but is documented</li> <li>UI button does nothing</li> <li>Feature flag doesn't enable feature</li> </ul> <p>\u2705 Unclear documentation - Can't figure out how to do something</p> <ul> <li>Documentation contradicts actual behavior</li> <li>Missing setup steps</li> <li>Confusing error messages</li> </ul>"},{"location":"v2/troubleshooting/common-errors/#dont-report-these","title":"Don't Report These","text":"<p>\u274c Configuration errors - Your setup is wrong</p> <ul> <li>Missing .env variables</li> <li>Wrong database credentials</li> <li>Port conflicts</li> </ul> <p>\u274c Environment issues - Your system is incompatible</p> <ul> <li>Old Docker version</li> <li>Missing dependencies</li> <li>Network restrictions</li> </ul> <p>\u274c User errors - Misunderstanding how to use</p> <ul> <li>Wrong API endpoint used</li> <li>Invalid data format</li> <li>Permission errors from lack of role</li> </ul>"},{"location":"v2/troubleshooting/common-errors/#how-to-report","title":"How to Report","text":"<ol> <li>Check this troubleshooting guide first</li> <li>Search existing GitHub issues</li> <li>If new, create issue with:</li> <li>Clear title describing problem</li> <li>Steps to reproduce</li> <li>Expected vs actual behavior</li> <li>Relevant logs (sanitize sensitive data)</li> <li>System information (Docker version, OS, etc.)</li> </ol>"},{"location":"v2/troubleshooting/common-errors/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/troubleshooting/common-errors/#general-documentation","title":"General Documentation","text":"<ul> <li>Installation Guide - Setup instructions</li> <li>Architecture Overview - System design</li> <li>API Reference - API endpoints</li> </ul>"},{"location":"v2/troubleshooting/common-errors/#specific-troubleshooting","title":"Specific Troubleshooting","text":"<ul> <li>Docker Issues - Container problems</li> <li>Database Issues - PostgreSQL errors</li> <li>Auth Issues - Authentication problems</li> <li>Geocoding Issues - Map and geocoding</li> <li>Email Issues - SMTP and templates</li> <li>Monitoring Issues - Prometheus and Grafana</li> </ul>"},{"location":"v2/troubleshooting/common-errors/#support","title":"Support","text":"<ul> <li>FAQ - Frequently asked questions</li> <li>Performance Optimization - Speed improvements</li> <li>GitHub Issues - Report bugs</li> </ul> <p>Last Updated: February 2026 Version: V2.0 Status: Complete</p>"},{"location":"v2/troubleshooting/database-issues/","title":"Database and PostgreSQL Issues","text":"<p>This guide covers PostgreSQL and database-related problems in Changemaker Lite V2.</p>"},{"location":"v2/troubleshooting/database-issues/#overview","title":"Overview","text":""},{"location":"v2/troubleshooting/database-issues/#database-architecture","title":"Database Architecture","text":"<p>Changemaker Lite V2 uses:</p> <ul> <li>PostgreSQL 16 - Primary database</li> <li>Prisma ORM - Main API (Express)</li> <li>Drizzle ORM - Media API (Fastify)</li> <li>Same database - Shared by both APIs</li> <li>Separate schemas - Tables owned by different ORMs</li> </ul>"},{"location":"v2/troubleshooting/database-issues/#database-connection-info","title":"Database Connection Info","text":"<pre><code># 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</code></pre>"},{"location":"v2/troubleshooting/database-issues/#essential-commands","title":"Essential Commands","text":"<pre><code># 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</code></pre>"},{"location":"v2/troubleshooting/database-issues/#connection-errors","title":"Connection Errors","text":""},{"location":"v2/troubleshooting/database-issues/#connection-refused","title":"Connection Refused","text":"<p>Severity: \ud83d\udd34 Critical</p>"},{"location":"v2/troubleshooting/database-issues/#symptoms","title":"Symptoms","text":"<p>API logs: <pre><code>Error: connect ECONNREFUSED 127.0.0.1:5433\nError: Can't reach database server at `v2-postgres:5432`\n</code></pre></p> <p>Or direct connection: <pre><code>psql: error: connection to server at \"localhost\" (127.0.0.1), port 5433 failed:\nConnection refused\n</code></pre></p>"},{"location":"v2/troubleshooting/database-issues/#common-causes","title":"Common Causes","text":"<ol> <li>Database not running - Container stopped</li> <li>Wrong connection string - Incorrect host/port</li> <li>Port not exposed - Missing port mapping</li> <li>Network issue - Container can't reach database</li> </ol>"},{"location":"v2/troubleshooting/database-issues/#solutions","title":"Solutions","text":"<p>Solution 1: Check database status</p> <pre><code># 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</code></pre> <p>Solution 2: Wait for database to be ready</p> <pre><code># 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</code></pre> <p>Solution 3: Verify connection string</p> <pre><code># 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</code></pre> <p>Solution 4: Test connection manually</p> <pre><code># 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</code></pre> <p>Solution 5: Check port mapping</p> <p>In <code>docker-compose.yml</code>:</p> <pre><code>v2-postgres:\n ports:\n - \"5433:5432\" # host:container\n</code></pre> <p>Verify:</p> <pre><code>docker compose ps v2-postgres\n\n# Should show:\n# PORTS: 0.0.0.0:5433->5432/tcp\n</code></pre>"},{"location":"v2/troubleshooting/database-issues/#prevention","title":"Prevention","text":"<ul> <li>Health checks - Wait for database health before starting API</li> <li>Connection retry - Retry connection on startup</li> <li>Correct env vars - Validate DATABASE_URL format</li> <li>Monitoring - Alert on connection failures</li> </ul>"},{"location":"v2/troubleshooting/database-issues/#too-many-clients","title":"Too Many Clients","text":"<p>Severity: \ud83d\udfe0 High</p>"},{"location":"v2/troubleshooting/database-issues/#symptoms_1","title":"Symptoms","text":"<pre><code>FATAL: sorry, too many clients already\n</code></pre> <p>Or:</p> <pre><code>Error: remaining connection slots are reserved for non-replication superuser connections\n</code></pre>"},{"location":"v2/troubleshooting/database-issues/#common-causes_1","title":"Common Causes","text":"<ol> <li>Connection leak - Connections not closed</li> <li>Pool too large - Connection pool size too high</li> <li>Multiple Prisma instances - Each creates own pool</li> <li>Long-running transactions - Holding connections</li> </ol>"},{"location":"v2/troubleshooting/database-issues/#solutions_1","title":"Solutions","text":"<p>Solution 1: Check active connections</p> <pre><code>-- 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</code></pre> <p>Solution 2: Kill idle connections</p> <pre><code>-- 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</code></pre> <p>Solution 3: Adjust connection pool</p> <p>In DATABASE_URL:</p> <pre><code># Limit connection pool size\nDATABASE_URL=\"postgresql://changemaker:password@v2-postgres:5432/changemaker_v2?connection_limit=10\"\n</code></pre> <p>Or in Prisma code:</p> <pre><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</code></pre> <p>Solution 4: Increase max connections</p> <p>In <code>docker-compose.yml</code>:</p> <pre><code>v2-postgres:\n command: postgres -c max_connections=200\n # Default is 100\n</code></pre> <p>Restart:</p> <pre><code>docker compose up -d v2-postgres\n</code></pre> <p>Verify:</p> <pre><code>SHOW max_connections;\n</code></pre> <p>Solution 5: Restart API to release connections</p> <pre><code># 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</code></pre>"},{"location":"v2/troubleshooting/database-issues/#prevention_1","title":"Prevention","text":"<ul> <li>Proper cleanup - Always close Prisma clients in tests</li> <li>Appropriate pool size - Balance performance vs connections</li> <li>Monitor connections - Alert when approaching max</li> <li>Idle timeout - Automatically close idle connections</li> </ul> <p>Connection Math</p> <p>Total connections = (number of API instances) \u00d7 (connection pool size) + (other clients)</p> <p>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</p> <p>Set max_connections to 2-3\u00d7 expected usage.</p>"},{"location":"v2/troubleshooting/database-issues/#authentication-failed","title":"Authentication Failed","text":"<p>Severity: \ud83d\udd34 Critical</p>"},{"location":"v2/troubleshooting/database-issues/#symptoms_2","title":"Symptoms","text":"<pre><code>FATAL: password authentication failed for user \"changemaker\"\n</code></pre> <p>Or:</p> <pre><code>FATAL: role \"changemaker\" does not exist\n</code></pre>"},{"location":"v2/troubleshooting/database-issues/#common-causes_2","title":"Common Causes","text":"<ol> <li>Wrong password - PASSWORD in DATABASE_URL doesn't match</li> <li>Wrong username - User doesn't exist</li> <li>Password changed - Database password changed but not .env</li> <li>Case sensitivity - PostgreSQL usernames are case-sensitive</li> </ol>"},{"location":"v2/troubleshooting/database-issues/#solutions_2","title":"Solutions","text":"<p>Solution 1: Verify credentials</p> <pre><code># 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</code></pre> <p>Solution 2: Test connection directly</p> <pre><code># 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</code></pre> <p>Solution 3: Check user exists</p> <pre><code># 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</code></pre> <p>Solution 4: Reset password</p> <pre><code># 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</code></pre> <p>Solution 5: Recreate database</p> <p>If completely broken:</p> <pre><code># 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</code></pre>"},{"location":"v2/troubleshooting/database-issues/#prevention_2","title":"Prevention","text":"<ul> <li>Secure passwords - Strong passwords in .env</li> <li>Consistent credentials - Same password in all places</li> <li>Version control .env.example - Template with placeholders</li> <li>Documentation - Document credential structure</li> </ul>"},{"location":"v2/troubleshooting/database-issues/#database-does-not-exist","title":"Database Does Not Exist","text":"<p>Severity: \ud83d\udfe0 High</p>"},{"location":"v2/troubleshooting/database-issues/#symptoms_3","title":"Symptoms","text":"<pre><code>FATAL: database \"changemaker_v2\" does not exist\n</code></pre>"},{"location":"v2/troubleshooting/database-issues/#common-causes_3","title":"Common Causes","text":"<ol> <li>First run - Database not created yet</li> <li>Wrong database name - Typo in DATABASE_URL</li> <li>Database deleted - Volume was removed</li> <li>Wrong postgres instance - Connected to different database</li> </ol>"},{"location":"v2/troubleshooting/database-issues/#solutions_3","title":"Solutions","text":"<p>Solution 1: Check database exists</p> <pre><code># 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</code></pre> <p>Solution 2: Create database</p> <pre><code># 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</code></pre> <p>Solution 3: Run migrations</p> <pre><code># 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</code></pre> <p>Solution 4: Check DATABASE_URL</p> <pre><code># 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</code></pre> <p>Solution 5: Full reset</p> <pre><code># \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</code></pre>"},{"location":"v2/troubleshooting/database-issues/#prevention_3","title":"Prevention","text":"<ul> <li>Initialization scripts - Auto-create database on first run</li> <li>Health checks - Verify database exists before app starts</li> <li>Migrations - Run migrations in deployment script</li> <li>Documentation - Clear setup instructions</li> </ul>"},{"location":"v2/troubleshooting/database-issues/#migration-errors","title":"Migration Errors","text":""},{"location":"v2/troubleshooting/database-issues/#migration-conflict","title":"Migration Conflict","text":"<p>Severity: \ud83d\udfe0 High</p>"},{"location":"v2/troubleshooting/database-issues/#symptoms_4","title":"Symptoms","text":"<pre><code>Error: Migration failed to apply cleanly to the shadow database.\nError: P3006 Migration `20260101000000_init` failed to apply cleanly to a temporary database.\n</code></pre> <p>Or:</p> <pre><code>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</code></pre>"},{"location":"v2/troubleshooting/database-issues/#common-causes_4","title":"Common Causes","text":"<ol> <li>Schema drift - Database schema doesn't match Prisma schema</li> <li>Non-nullable column - Adding required field to table with data</li> <li>Conflicting migration - Different migration with same name</li> <li>Shadow database issue - Can't create shadow database</li> </ol>"},{"location":"v2/troubleshooting/database-issues/#solutions_4","title":"Solutions","text":"<p>Solution 1: Check migration status</p> <pre><code># View migration history\ndocker compose exec api npx prisma migrate status\n\n# Shows:\n# - Applied migrations\n# - Pending migrations\n# - Failed migrations\n</code></pre> <p>Solution 2: Add default value for new field</p> <p>If adding non-nullable column to table with existing data:</p> <pre><code>// 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</code></pre> <p>Or use two-step migration:</p> <pre><code>-- 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</code></pre> <p>Solution 3: Reset database (dev only)</p> <pre><code># \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</code></pre> <p>Solution 4: Manually fix schema drift</p> <pre><code># 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</code></pre> <p>Solution 5: Mark migration as applied (if already applied manually)</p> <pre><code># If you manually ran migration SQL, mark as applied:\ndocker compose exec api npx prisma migrate resolve --applied \"20260201000000_migration_name\"\n</code></pre>"},{"location":"v2/troubleshooting/database-issues/#prevention_4","title":"Prevention","text":"<ul> <li>Development workflow - Use <code>prisma migrate dev</code> in dev</li> <li>Production workflow - Use <code>prisma migrate deploy</code> in prod</li> <li>Never edit migrations - Don't modify files in migrations/</li> <li>Test migrations - Test on copy of prod data first</li> </ul>"},{"location":"v2/troubleshooting/database-issues/#schema-drift","title":"Schema Drift","text":"<p>Severity: \ud83d\udfe1 Medium</p>"},{"location":"v2/troubleshooting/database-issues/#symptoms_5","title":"Symptoms","text":"<pre><code>Warning: Your database schema is not in sync with your Prisma schema.\n</code></pre> <p>Or:</p> <pre><code>Error: P2021 The table `main.NewTable` does not exist in the current database.\n</code></pre>"},{"location":"v2/troubleshooting/database-issues/#common-causes_5","title":"Common Causes","text":"<ol> <li>Manual schema changes - Changed database without migration</li> <li>Missing migrations - Migrations not run on this database</li> <li>Different environment - Prod vs dev schema mismatch</li> <li>Failed migration - Migration partially applied</li> </ol>"},{"location":"v2/troubleshooting/database-issues/#solutions_5","title":"Solutions","text":"<p>Solution 1: Detect drift</p> <pre><code># 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</code></pre> <p>Solution 2: Create migration from drift</p> <pre><code># Generate migration to fix drift\ndocker compose exec api npx prisma migrate dev --name fix_drift\n\n# Reviews changes and creates migration\n</code></pre> <p>Solution 3: Pull schema from database</p> <pre><code># 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</code></pre> <p>Solution 4: Deploy missing migrations</p> <pre><code># 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</code></pre> <p>Solution 5: Reset and re-migrate (dev only)</p> <pre><code># \u26a0\ufe0f DELETES ALL DATA!\ndocker compose exec api npx prisma migrate reset\n\n# Applies all migrations fresh\n</code></pre>"},{"location":"v2/troubleshooting/database-issues/#prevention_5","title":"Prevention","text":"<ul> <li>Never manual schema changes - Always use migrations</li> <li>Consistent workflow - Same process in all environments</li> <li>CI/CD validation - Check for drift in CI pipeline</li> <li>Documentation - Document migration process</li> </ul>"},{"location":"v2/troubleshooting/database-issues/#failed-migration-rollback","title":"Failed Migration Rollback","text":"<p>Severity: \ud83d\udd34 Critical</p>"},{"location":"v2/troubleshooting/database-issues/#symptoms_6","title":"Symptoms","text":"<pre><code>Error: Migration failed. Cannot rollback without losing data.\n</code></pre> <p>Or:</p> <pre><code>Error: Database is in an inconsistent state after a failed migration\n</code></pre>"},{"location":"v2/troubleshooting/database-issues/#common-causes_6","title":"Common Causes","text":"<ol> <li>Data migration failed - Migration includes data changes that failed</li> <li>Constraint violation - Migration violates database constraints</li> <li>No rollback - Prisma doesn't support automatic rollback</li> <li>Partial application - Migration partially applied before error</li> </ol>"},{"location":"v2/troubleshooting/database-issues/#solutions_6","title":"Solutions","text":"<p>Solution 1: Mark migration as rolled back</p> <pre><code># Mark as failed (doesn't undo changes)\ndocker compose exec api npx prisma migrate resolve --rolled-back \"20260201000000_migration_name\"\n</code></pre> <p>Solution 2: Manually revert changes</p> <pre><code>-- 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</code></pre> <p>Solution 3: Restore from backup</p> <pre><code># 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</code></pre> <p>Solution 4: Fix forward</p> <p>Instead of rolling back, fix the issue and continue:</p> <pre><code># 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</code></pre> <p>Solution 5: Baseline from current state</p> <p>If database is in unknown state:</p> <pre><code># 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</code></pre>"},{"location":"v2/troubleshooting/database-issues/#prevention_6","title":"Prevention","text":"<ul> <li>Test migrations - Test on copy of prod data first</li> <li>Backup before migrate - Always backup before production migration</li> <li>Reversible migrations - Design migrations to be reversible</li> <li>Small migrations - Small, focused migrations easier to fix</li> </ul> <p>Prisma Doesn't Auto-Rollback</p> <p>Prisma Migrate does NOT automatically rollback failed migrations. You must manually fix issues.</p>"},{"location":"v2/troubleshooting/database-issues/#query-performance","title":"Query Performance","text":""},{"location":"v2/troubleshooting/database-issues/#slow-queries","title":"Slow Queries","text":"<p>Severity: \ud83d\udfe1 Medium to \ud83d\udfe0 High</p>"},{"location":"v2/troubleshooting/database-issues/#symptoms_7","title":"Symptoms","text":"<p>API requests taking seconds to respond:</p> <pre><code>GET /api/users - 5000ms\n</code></pre> <p>Database logs show slow queries:</p> <pre><code>LOG: duration: 4521.234 ms statement: SELECT * FROM \"User\" WHERE ...\n</code></pre>"},{"location":"v2/troubleshooting/database-issues/#common-causes_7","title":"Common Causes","text":"<ol> <li>Missing indexes - Querying without index</li> <li>Full table scan - WHERE clause doesn't use index</li> <li>N+1 queries - Multiple queries instead of JOIN</li> <li>Large result set - Fetching too many rows</li> <li>Complex query - Too many JOINs or subqueries</li> </ol>"},{"location":"v2/troubleshooting/database-issues/#solutions_7","title":"Solutions","text":"<p>Solution 1: Enable slow query logging</p> <p>In <code>docker-compose.yml</code>:</p> <pre><code>v2-postgres:\n command: postgres -c log_min_duration_statement=1000\n # Logs queries taking > 1 second\n</code></pre> <p>Restart:</p> <pre><code>docker compose up -d v2-postgres\n\n# View slow query log\ndocker compose logs v2-postgres | grep \"duration:\"\n</code></pre> <p>Solution 2: Analyze query</p> <pre><code>-- 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</code></pre> <p>Solution 3: Add indexes</p> <pre><code>// 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</code></pre> <p>Create migration:</p> <pre><code>docker compose exec api npx prisma migrate dev --name add_user_name_index\n</code></pre> <p>Verify index used:</p> <pre><code>EXPLAIN SELECT * FROM \"User\" WHERE name = 'John';\n-- Should show: Index Scan using User_name_idx\n</code></pre> <p>Solution 4: Fix N+1 queries</p> <pre><code>// 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</code></pre> <p>Solution 5: Limit result size</p> <pre><code>// 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</code></pre>"},{"location":"v2/troubleshooting/database-issues/#prevention_7","title":"Prevention","text":"<ul> <li>Index frequently queried fields - email, createdAt, etc.</li> <li>Use includes - Avoid N+1 queries</li> <li>Paginate results - Never fetch all rows</li> <li>Monitor query performance - Alert on slow queries</li> </ul>"},{"location":"v2/troubleshooting/database-issues/#missing-indexes","title":"Missing Indexes","text":"<p>Severity: \ud83d\udfe1 Medium</p>"},{"location":"v2/troubleshooting/database-issues/#symptoms_8","title":"Symptoms","text":"<p>Slow queries on filtered/sorted columns:</p> <pre><code>SELECT * FROM \"Location\" WHERE \"postalCode\" = 'M5H 2N2';\n-- Slow without index on postalCode\n</code></pre>"},{"location":"v2/troubleshooting/database-issues/#common-causes_8","title":"Common Causes","text":"<ol> <li>No index on filter column - WHERE clause column not indexed</li> <li>No index on sort column - ORDER BY column not indexed</li> <li>No index on foreign key - JOIN column not indexed</li> <li>Composite index needed - Multiple columns in WHERE</li> </ol>"},{"location":"v2/troubleshooting/database-issues/#solutions_8","title":"Solutions","text":"<p>Solution 1: Identify missing indexes</p> <pre><code>-- 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</code></pre> <p>Solution 2: Add single-column index</p> <pre><code>model Location {\n id String @id @default(uuid())\n address String\n postalCode String\n\n @@index([postalCode]) // Add index\n}\n</code></pre> <p>Solution 3: Add composite index</p> <p>For queries filtering on multiple columns:</p> <pre><code>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</code></pre> <p>Solution 4: Add index on foreign key</p> <pre><code>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</code></pre> <p>Solution 5: Create migration</p> <pre><code># 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</code></pre>"},{"location":"v2/troubleshooting/database-issues/#prevention_8","title":"Prevention","text":"<ul> <li>Index foreign keys - Always index foreign keys</li> <li>Index filter columns - Index columns used in WHERE</li> <li>Index sort columns - Index columns used in ORDER BY</li> <li>Monitor query patterns - Add indexes based on actual usage</li> </ul> <p>Index Guidelines</p> <ul> <li>Unique constraints auto-create indexes</li> <li>Foreign keys should be indexed</li> <li>Columns in WHERE/ORDER BY/GROUP BY are candidates</li> <li>Don't over-index (slows down writes)</li> </ul>"},{"location":"v2/troubleshooting/database-issues/#n1-queries","title":"N+1 Queries","text":"<p>Severity: \ud83d\udfe0 High</p>"},{"location":"v2/troubleshooting/database-issues/#symptoms_9","title":"Symptoms","text":"<p>API slow when fetching related data:</p> <pre><code>GET /api/campaigns - 2000ms\n</code></pre> <p>Database logs show many similar queries:</p> <pre><code>SELECT * FROM \"CampaignEmail\" WHERE \"campaignId\" = 'uuid1'\nSELECT * FROM \"CampaignEmail\" WHERE \"campaignId\" = 'uuid2'\nSELECT * FROM \"CampaignEmail\" WHERE \"campaignId\" = 'uuid3'\n...\n</code></pre>"},{"location":"v2/troubleshooting/database-issues/#common-causes_9","title":"Common Causes","text":"<ol> <li>No eager loading - Fetching relations in loop</li> <li>Separate queries - Not using include/select</li> <li>Nested loops - Multiple levels of relations</li> </ol>"},{"location":"v2/troubleshooting/database-issues/#solutions_9","title":"Solutions","text":"<p>Solution 1: Detect N+1 queries</p> <p>Enable query logging:</p> <pre><code>// In api/src/config/database.ts\nexport const prisma = new PrismaClient({\n log: ['query'], // Log all queries\n});\n</code></pre> <p>Look for repeated patterns:</p> <pre><code>Query: SELECT * FROM \"Campaign\"\nQuery: SELECT * FROM \"CampaignEmail\" WHERE \"campaignId\" = '...'\nQuery: SELECT * FROM \"CampaignEmail\" WHERE \"campaignId\" = '...'\nQuery: SELECT * FROM \"CampaignEmail\" WHERE \"campaignId\" = '...'\n</code></pre> <p>Solution 2: Use include</p> <pre><code>// 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</code></pre> <p>Solution 3: Nested includes</p> <pre><code>// 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</code></pre> <p>Solution 4: Select only needed fields</p> <pre><code>// 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</code></pre> <p>Solution 5: Use findUnique with include for single record</p> <pre><code>// 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</code></pre>"},{"location":"v2/troubleshooting/database-issues/#prevention_9","title":"Prevention","text":"<ul> <li>Always use include - Load relations in single query</li> <li>Enable query logging - Monitor for N+1 patterns</li> <li>Code review - Check for loops with queries</li> <li>Testing - Load test with realistic data</li> </ul>"},{"location":"v2/troubleshooting/database-issues/#connection-pool-exhaustion","title":"Connection Pool Exhaustion","text":"<p>Severity: \ud83d\udfe0 High</p>"},{"location":"v2/troubleshooting/database-issues/#symptoms_10","title":"Symptoms","text":"<pre><code>Error: Timed out fetching a new connection from the connection pool.\n</code></pre> <p>Or:</p> <pre><code>Error: Can't create connection pool - all connections are in use\n</code></pre> <p>API becomes unresponsive.</p>"},{"location":"v2/troubleshooting/database-issues/#common-causes_10","title":"Common Causes","text":"<ol> <li>Pool too small - Not enough connections for load</li> <li>Connections not released - Long-running transactions</li> <li>Too many workers - BullMQ workers using all connections</li> <li>Connection leak - Connections never closed</li> </ol>"},{"location":"v2/troubleshooting/database-issues/#solutions_10","title":"Solutions","text":"<p>Solution 1: Check pool size</p> <pre><code># 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</code></pre> <p>Solution 2: Increase pool size</p> <pre><code># In .env\nDATABASE_URL=\"postgresql://changemaker:password@v2-postgres:5432/changemaker_v2?connection_limit=20\"\n\n# Restart API\ndocker compose restart api\n</code></pre> <p>Solution 3: Check active connections</p> <pre><code>-- 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</code></pre> <p>Solution 4: Find long-running transactions</p> <pre><code>-- 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</code></pre> <p>Solution 5: Configure pool timeout</p> <pre><code># Increase timeout from 10s to 30s\nDATABASE_URL=\"postgresql://...?connection_limit=20&pool_timeout=30\"\n</code></pre>"},{"location":"v2/troubleshooting/database-issues/#prevention_10","title":"Prevention","text":"<ul> <li>Appropriate pool size - Size based on load</li> <li>Release connections - Always close transactions</li> <li>Monitor pool usage - Alert when near limit</li> <li>Connection timeout - Kill stuck connections</li> </ul> <p>Pool Sizing</p> <p>Recommended pool size = (CPU cores \u00d7 2) + effective_spindle_count</p> <p>For most applications: 10-20 connections per API instance</p>"},{"location":"v2/troubleshooting/database-issues/#data-issues","title":"Data Issues","text":""},{"location":"v2/troubleshooting/database-issues/#duplicate-records","title":"Duplicate Records","text":"<p>Severity: \ud83d\udfe1 Medium</p>"},{"location":"v2/troubleshooting/database-issues/#symptoms_11","title":"Symptoms","text":"<pre><code>Error: Unique constraint failed on the fields: (`email`)\n</code></pre> <p>Or finding multiple records:</p> <pre><code>SELECT email, count(*)\nFROM \"User\"\nGROUP BY email\nHAVING count(*) > 1;\n-- Returns duplicates\n</code></pre>"},{"location":"v2/troubleshooting/database-issues/#common-causes_11","title":"Common Causes","text":"<ol> <li>Race condition - Two creates at exact same time</li> <li>Import error - CSV import created duplicates</li> <li>Migration bug - Migration didn't handle duplicates</li> <li>No unique constraint - Database allows duplicates</li> </ol>"},{"location":"v2/troubleshooting/database-issues/#solutions_11","title":"Solutions","text":"<p>Solution 1: Find duplicates</p> <pre><code>-- 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</code></pre> <p>Solution 2: Delete duplicates (keep oldest)</p> <pre><code>-- 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</code></pre> <p>Solution 3: Merge duplicates</p> <pre><code>-- 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</code></pre> <p>Solution 4: Add unique constraint</p> <pre><code>model User {\n id String @id @default(uuid())\n email String @unique // Ensures uniqueness\n}\n</code></pre> <p>Create migration:</p> <pre><code># 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</code></pre> <p>Solution 5: Prevent in application code</p> <pre><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</code></pre>"},{"location":"v2/troubleshooting/database-issues/#prevention_11","title":"Prevention","text":"<ul> <li>Unique constraints - Database enforces uniqueness</li> <li>Use upsert - Update or create atomically</li> <li>Validation - Check existence before creating</li> <li>Transaction isolation - Prevent race conditions</li> </ul>"},{"location":"v2/troubleshooting/database-issues/#constraint-violations","title":"Constraint Violations","text":"<p>Severity: \ud83d\udfe1 Medium</p>"},{"location":"v2/troubleshooting/database-issues/#symptoms_12","title":"Symptoms","text":"<pre><code>Error: Foreign key constraint failed on the field: `campaignId`\n</code></pre> <p>Or:</p> <pre><code>Error: Null value in column \"name\" violates not-null constraint\n</code></pre> <p>Or:</p> <pre><code>Error: Check constraint \"positive_age\" is violated\n</code></pre>"},{"location":"v2/troubleshooting/database-issues/#common-causes_12","title":"Common Causes","text":"<ol> <li>Foreign key missing - Referenced record doesn't exist</li> <li>Null in required field - NULL when NOT NULL constraint</li> <li>Check constraint - Value violates CHECK constraint</li> <li>Data type mismatch - Wrong type for column</li> </ol>"},{"location":"v2/troubleshooting/database-issues/#solutions_12","title":"Solutions","text":"<p>Solution 1: Verify foreign key exists</p> <pre><code>-- Check if campaign exists\nSELECT id FROM \"Campaign\" WHERE id = 'campaign-uuid';\n\n-- If not found, create parent first\n</code></pre> <p>Solution 2: Provide required fields</p> <pre><code>// 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</code></pre> <p>Solution 3: Handle check constraints</p> <pre><code>-- 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</code></pre> <p>Solution 4: Fix data type</p> <pre><code>// 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</code></pre> <p>Solution 5: Use transactions for dependent creates</p> <pre><code>// 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</code></pre>"},{"location":"v2/troubleshooting/database-issues/#prevention_12","title":"Prevention","text":"<ul> <li>TypeScript types - Catch type errors at compile time</li> <li>Zod validation - Validate before database operations</li> <li>Foreign key checks - Verify parent exists</li> <li>Transactions - Atomic multi-step operations</li> </ul>"},{"location":"v2/troubleshooting/database-issues/#data-corruption","title":"Data Corruption","text":"<p>Severity: \ud83d\udd34 Critical</p>"},{"location":"v2/troubleshooting/database-issues/#symptoms_13","title":"Symptoms","text":"<ul> <li>Invalid JSON in JSON columns</li> <li>Truncated text</li> <li>Wrong character encoding</li> <li>Inconsistent relationships</li> </ul> <pre><code>SELECT * FROM \"Campaign\" WHERE \"settings\"::text LIKE '%\\\\u0000%';\n-- Null bytes in JSON\n</code></pre>"},{"location":"v2/troubleshooting/database-issues/#common-causes_13","title":"Common Causes","text":"<ol> <li>Bad import - CSV/JSON import with bad data</li> <li>Encoding issues - Wrong character encoding</li> <li>Failed migration - Migration partially applied</li> <li>Application bug - Code writing bad data</li> </ol>"},{"location":"v2/troubleshooting/database-issues/#solutions_13","title":"Solutions","text":"<p>Solution 1: Detect corruption</p> <pre><code>-- 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</code></pre> <p>Solution 2: Fix invalid JSON</p> <pre><code>-- Replace invalid JSON with valid default\nUPDATE \"Campaign\"\nSET settings = '{}'::jsonb\nWHERE settings IS NOT NULL\n AND settings::text !~ '^[\\[\\{].*[\\]\\}]$';\n</code></pre> <p>Solution 3: Fix encoding</p> <pre><code>-- Convert encoding\nUPDATE \"Location\"\nSET address = convert_from(convert_to(address, 'LATIN1'), 'UTF8')\nWHERE address ~ '[^\\x00-\\x7F]';\n</code></pre> <p>Solution 4: Restore from backup</p> <pre><code># If corruption is widespread, restore from backup\ndocker compose exec -T v2-postgres psql -U changemaker -d changemaker_v2 < backup-before-corruption.sql\n</code></pre> <p>Solution 5: Prevent future corruption</p> <pre><code>// 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</code></pre>"},{"location":"v2/troubleshooting/database-issues/#prevention_13","title":"Prevention","text":"<ul> <li>Input validation - Validate all inputs with Zod</li> <li>UTF-8 encoding - Use UTF-8 everywhere</li> <li>Regular backups - Daily backups</li> <li>Data integrity checks - Regular validation scripts</li> </ul>"},{"location":"v2/troubleshooting/database-issues/#prisma-studio-issues","title":"Prisma Studio Issues","text":""},{"location":"v2/troubleshooting/database-issues/#wont-connect","title":"Won't Connect","text":"<p>Severity: \ud83d\udfe2 Low</p>"},{"location":"v2/troubleshooting/database-issues/#symptoms_14","title":"Symptoms","text":"<pre><code>docker compose exec api npx prisma studio\n</code></pre> <p>Opens browser but shows:</p> <pre><code>Error connecting to database\n</code></pre>"},{"location":"v2/troubleshooting/database-issues/#solutions_14","title":"Solutions","text":"<p>Solution 1: Check DATABASE_URL</p> <pre><code># Verify DATABASE_URL in container\ndocker compose exec api sh -c 'echo $DATABASE_URL'\n\n# Should be valid connection string\n</code></pre> <p>Solution 2: Test connection</p> <pre><code># Test database connection\ndocker compose exec api npx prisma db pull\n\n# If fails, connection string is wrong\n</code></pre> <p>Solution 3: Use correct port</p> <p>Prisma Studio runs on port 5555 by default. If port conflicts:</p> <pre><code># Use different port\ndocker compose exec api npx prisma studio --port 5556\n</code></pre> <p>Solution 4: Check database is running</p> <pre><code>docker compose ps v2-postgres\n# Must be \"Up\"\n</code></pre>"},{"location":"v2/troubleshooting/database-issues/#slow-loading","title":"Slow Loading","text":"<p>Severity: \ud83d\udfe2 Low</p>"},{"location":"v2/troubleshooting/database-issues/#symptoms_15","title":"Symptoms","text":"<p>Prisma Studio takes minutes to load tables with many rows.</p>"},{"location":"v2/troubleshooting/database-issues/#solutions_15","title":"Solutions","text":"<p>Solution 1: Limit rows</p> <p>Prisma Studio loads all rows. For large tables, use SQL instead:</p> <pre><code># Instead of Prisma Studio for large tables\ndocker compose exec v2-postgres psql -U changemaker -d changemaker_v2\n</code></pre> <p>Solution 2: Add pagination</p> <pre><code>-- In psql, paginate manually\nSELECT * FROM \"Location\" LIMIT 50 OFFSET 0;\nSELECT * FROM \"Location\" LIMIT 50 OFFSET 50;\n</code></pre>"},{"location":"v2/troubleshooting/database-issues/#drizzle-kit-issues","title":"Drizzle Kit Issues","text":""},{"location":"v2/troubleshooting/database-issues/#push-failures","title":"Push Failures","text":"<p>Severity: \ud83d\udfe0 High</p>"},{"location":"v2/troubleshooting/database-issues/#symptoms_16","title":"Symptoms","text":"<pre><code>docker compose exec api npx drizzle-kit push\n</code></pre> <p>Fails with:</p> <pre><code>Error: Failed to push schema changes\n</code></pre>"},{"location":"v2/troubleshooting/database-issues/#solutions_16","title":"Solutions","text":"<p>Solution 1: Check Drizzle config</p> <pre><code>// 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</code></pre> <p>Solution 2: Verify schema file</p> <pre><code># 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</code></pre> <p>Solution 3: Check for conflicts with Prisma tables</p> <p>Drizzle and Prisma share same database. Ensure table names don't conflict:</p> <pre><code>// Drizzle tables\nexport const videos = pgTable('media_videos', { ... });\nexport const reactions = pgTable('media_reactions', { ... });\n\n// Prisma uses: User, Campaign, etc. (no conflict)\n</code></pre> <p>Solution 4: Manually apply schema</p> <pre><code># 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</code></pre>"},{"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":"<p>Severity: \ud83d\udfe0 High</p>"},{"location":"v2/troubleshooting/database-issues/#symptoms_17","title":"Symptoms","text":"<pre><code>docker compose exec v2-postgres pg_dump -U changemaker changemaker_v2 > backup.sql\n</code></pre> <p>Fails with:</p> <pre><code>pg_dump: error: connection to server on socket \"/var/run/postgresql/.s.PGSQL.5432\" failed: No such file or directory\n</code></pre>"},{"location":"v2/troubleshooting/database-issues/#solutions_17","title":"Solutions","text":"<p>Solution 1: Use correct connection</p> <pre><code># 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</code></pre> <p>Solution 2: Backup to file inside container</p> <pre><code># 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</code></pre> <p>Solution 3: Use backup script</p> <pre><code># Use provided backup script\n./scripts/backup.sh\n</code></pre>"},{"location":"v2/troubleshooting/database-issues/#restore-failures","title":"Restore Failures","text":"<p>Severity: \ud83d\udd34 Critical</p>"},{"location":"v2/troubleshooting/database-issues/#symptoms_18","title":"Symptoms","text":"<pre><code>docker compose exec -T v2-postgres psql -U changemaker -d changemaker_v2 < backup.sql\n</code></pre> <p>Fails with errors:</p> <pre><code>ERROR: relation \"User\" already exists\nERROR: duplicate key value violates unique constraint\n</code></pre>"},{"location":"v2/troubleshooting/database-issues/#solutions_18","title":"Solutions","text":"<p>Solution 1: Drop database first</p> <pre><code># \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</code></pre> <p>Solution 2: Use --clean flag</p> <pre><code># 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</code></pre> <p>Solution 3: Ignore errors for existing objects</p> <pre><code># 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</code></pre>"},{"location":"v2/troubleshooting/database-issues/#useful-commands","title":"Useful Commands","text":""},{"location":"v2/troubleshooting/database-issues/#query-database","title":"Query Database","text":"<pre><code># 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</code></pre>"},{"location":"v2/troubleshooting/database-issues/#database-inspection","title":"Database Inspection","text":"<pre><code># 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</code></pre>"},{"location":"v2/troubleshooting/database-issues/#performance-analysis","title":"Performance Analysis","text":"<pre><code># 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</code></pre>"},{"location":"v2/troubleshooting/database-issues/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/troubleshooting/database-issues/#database-documentation","title":"Database Documentation","text":"<ul> <li>Database Issues - This guide</li> <li>Installation Guide - Initial database setup</li> <li>Architecture Overview - Database architecture</li> </ul>"},{"location":"v2/troubleshooting/database-issues/#other-troubleshooting","title":"Other Troubleshooting","text":"<ul> <li>Common Errors - General errors</li> <li>Docker Issues - Container problems</li> <li>Performance Optimization - Database tuning</li> </ul>"},{"location":"v2/troubleshooting/database-issues/#postgresql-resources","title":"PostgreSQL Resources","text":"<ul> <li>PostgreSQL Documentation</li> <li>Prisma Documentation</li> <li>Drizzle Documentation</li> </ul> <p>Last Updated: February 2026 Version: V2.0 Status: Complete</p>"},{"location":"v2/troubleshooting/docker-issues/","title":"Docker and Container Issues","text":"<p>This guide covers Docker-specific problems in Changemaker Lite V2.</p>"},{"location":"v2/troubleshooting/docker-issues/#overview","title":"Overview","text":""},{"location":"v2/troubleshooting/docker-issues/#docker-troubleshooting-approach","title":"Docker Troubleshooting Approach","text":"<ol> <li>Check status - Are containers running?</li> <li>Read logs - What do container logs show?</li> <li>Inspect configuration - Is docker-compose.yml correct?</li> <li>Test connectivity - Can containers communicate?</li> <li>Resource check - Enough CPU/memory/disk?</li> </ol>"},{"location":"v2/troubleshooting/docker-issues/#essential-docker-commands","title":"Essential Docker Commands","text":"<pre><code># 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</code></pre>"},{"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":"<p>Severity: \ud83d\udd34 Critical</p>"},{"location":"v2/troubleshooting/docker-issues/#symptoms","title":"Symptoms","text":"<pre><code>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</code></pre> <p>Or:</p> <pre><code>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</code></pre>"},{"location":"v2/troubleshooting/docker-issues/#common-causes","title":"Common Causes","text":"<ol> <li>Another container using port - Different Docker project</li> <li>Host process using port - npm dev server running</li> <li>Previous container not stopped - Old container still running</li> <li>Port conflict in docker-compose.yml - Two services same port</li> </ol>"},{"location":"v2/troubleshooting/docker-issues/#solutions","title":"Solutions","text":"<p>Solution 1: Find what's using the port</p> <pre><code># Linux/Mac\nsudo lsof -i :4000\n\n# Or with netstat\nnetstat -tuln | grep :4000\n\n# Windows\nnetstat -ano | findstr :4000\n</code></pre> <p>Output shows: <pre><code>COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME\nnode 12345 user 23u IPv4 123456 0t0 TCP *:4000 (LISTEN)\n</code></pre></p> <p>Solution 2: Stop conflicting process</p> <pre><code># 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</code></pre> <p>Solution 3: Change port in docker-compose.yml</p> <pre><code># In docker-compose.yml\napi:\n ports:\n - \"4002:4000\" # Changed from 4000:4000\n</code></pre> <p>Then:</p> <pre><code># Restart with new port\ndocker compose up -d api\n\n# Update .env to use new port\nVITE_API_URL=http://localhost:4002\n</code></pre> <p>Solution 4: Stop all and restart</p> <pre><code># 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</code></pre>"},{"location":"v2/troubleshooting/docker-issues/#prevention","title":"Prevention","text":"<ul> <li>Use unique ports - Avoid common ports (3000, 4000, 8000, 8080)</li> <li>Stop properly - Always use <code>docker compose down</code></li> <li>Check before start - Run <code>docker compose ps</code> first</li> <li>Document ports - Keep port reference updated</li> </ul>"},{"location":"v2/troubleshooting/docker-issues/#volume-mount-errors","title":"Volume Mount Errors","text":"<p>Severity: \ud83d\udd34 Critical</p>"},{"location":"v2/troubleshooting/docker-issues/#symptoms_1","title":"Symptoms","text":"<pre><code>Error response from daemon: invalid mount config for type \"bind\":\nbind source path does not exist: /home/user/changemaker.lite/uploads\n</code></pre> <p>Or:</p> <pre><code>Error: EACCES: permission denied, open '/media/local/inbox/video.mp4'\n</code></pre>"},{"location":"v2/troubleshooting/docker-issues/#common-causes_1","title":"Common Causes","text":"<ol> <li>Path doesn't exist - Directory not created</li> <li>Permission denied - Container can't access directory</li> <li>Wrong path - Typo in docker-compose.yml</li> <li>SELinux blocking - Linux security policy</li> </ol>"},{"location":"v2/troubleshooting/docker-issues/#solutions_1","title":"Solutions","text":"<p>Solution 1: Create missing directories</p> <pre><code># 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</code></pre> <p>Solution 2: Fix permissions</p> <pre><code># 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</code></pre> <p>Solution 3: Check volume configuration</p> <p>In <code>docker-compose.yml</code>:</p> <pre><code>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</code></pre> <p>Solution 4: Disable SELinux (last resort)</p> <pre><code># 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</code></pre> <p>Solution 5: Verify mount inside container</p> <pre><code># 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</code></pre>"},{"location":"v2/troubleshooting/docker-issues/#prevention_1","title":"Prevention","text":"<ul> <li>Create directories first - Before <code>docker compose up</code></li> <li>Set permissions early - In setup script</li> <li>Use relative paths - Start with <code>./</code> in docker-compose.yml</li> <li>Document requirements - List all required directories</li> </ul>"},{"location":"v2/troubleshooting/docker-issues/#missing-environment-variables","title":"Missing Environment Variables","text":"<p>Severity: \ud83d\udd34 Critical</p>"},{"location":"v2/troubleshooting/docker-issues/#symptoms_2","title":"Symptoms","text":"<p>Container logs show:</p> <pre><code>Error: DATABASE_URL is required\n</code></pre> <p>Or:</p> <pre><code>ZodError: [\n {\n \"code\": \"invalid_type\",\n \"expected\": \"string\",\n \"received\": \"undefined\",\n \"path\": [\"SMTP_HOST\"],\n \"message\": \"Required\"\n }\n]\n</code></pre> <p>Or container exits immediately:</p> <pre><code>changemaker-lite-api-1 exited with code 1\n</code></pre>"},{"location":"v2/troubleshooting/docker-issues/#common-causes_2","title":"Common Causes","text":"<ol> <li>.env not found - Missing .env file</li> <li>Variable not set - Missing required variable</li> <li>Wrong .env location - .env not in project root</li> <li>Syntax error - Malformed .env file</li> </ol>"},{"location":"v2/troubleshooting/docker-issues/#solutions_2","title":"Solutions","text":"<p>Solution 1: Check .env exists</p> <pre><code># Verify .env file\nls -la .env\n\n# If missing, copy from example\ncp .env.example .env\n</code></pre> <p>Solution 2: Find missing variables</p> <pre><code># 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</code></pre> <p>Solution 3: Add missing variables</p> <pre><code># Edit .env\nnano .env\n\n# Add missing variable\nSMTP_HOST=smtp.gmail.com\n\n# Save and restart\ndocker compose restart api\n</code></pre> <p>Solution 4: Validate .env format</p> <pre><code># 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</code></pre> <p>Solution 5: Check which variables are loaded</p> <pre><code># 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</code></pre>"},{"location":"v2/troubleshooting/docker-issues/#prevention_2","title":"Prevention","text":"<ul> <li>Use .env.example - Keep template updated</li> <li>Validation on startup - Zod validates env in <code>config/env.ts</code></li> <li>Documentation - Document all required variables</li> <li>Setup script - Validate .env before starting</li> </ul>"},{"location":"v2/troubleshooting/docker-issues/#health-check-failures","title":"Health Check Failures","text":"<p>Severity: \ud83d\udfe0 High</p>"},{"location":"v2/troubleshooting/docker-issues/#symptoms_3","title":"Symptoms","text":"<pre><code>docker compose ps\n</code></pre> <p>Shows:</p> <pre><code>NAME STATUS\napi Up 30 seconds (unhealthy)\nv2-postgres Up 1 minute (healthy)\n</code></pre> <p>Or logs show:</p> <pre><code>Health check failed\n</code></pre>"},{"location":"v2/troubleshooting/docker-issues/#common-causes_3","title":"Common Causes","text":"<ol> <li>Service not ready - Still starting up</li> <li>Health check endpoint failing - /health returns error</li> <li>Timeout too short - Service needs more time</li> <li>Dependencies not ready - Database not connected</li> </ol>"},{"location":"v2/troubleshooting/docker-issues/#solutions_3","title":"Solutions","text":"<p>Solution 1: Check health check configuration</p> <p>In <code>docker-compose.yml</code>:</p> <pre><code>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</code></pre> <p>Solution 2: Test health endpoint manually</p> <pre><code># 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</code></pre> <p>Solution 3: View health check logs</p> <pre><code># 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</code></pre> <p>Solution 4: Increase timeout/interval</p> <pre><code>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</code></pre> <p>Solution 5: Check service logs</p> <pre><code># 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</code></pre>"},{"location":"v2/troubleshooting/docker-issues/#prevention_3","title":"Prevention","text":"<ul> <li>Reasonable timeouts - Allow enough time for startup</li> <li>Accurate health checks - Check actual readiness</li> <li>Monitor health - Alert on unhealthy containers</li> <li>Dependencies - Use <code>depends_on</code> with <code>condition: service_healthy</code></li> </ul>"},{"location":"v2/troubleshooting/docker-issues/#container-crashes","title":"Container Crashes","text":""},{"location":"v2/troubleshooting/docker-issues/#out-of-memory","title":"Out of Memory","text":"<p>Severity: \ud83d\udd34 Critical</p>"},{"location":"v2/troubleshooting/docker-issues/#symptoms_4","title":"Symptoms","text":"<p>Container logs show:</p> <pre><code><--- 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</code></pre> <p>Or:</p> <pre><code>Killed\n</code></pre> <p>Or <code>docker compose ps</code> shows:</p> <pre><code>api Exit 137\n</code></pre>"},{"location":"v2/troubleshooting/docker-issues/#common-causes_4","title":"Common Causes","text":"<ol> <li>Memory leak - Application leaking memory</li> <li>Large dataset - Processing too much data</li> <li>Too many connections - Database connection pool too large</li> <li>Container limit - Memory limit too low</li> </ol>"},{"location":"v2/troubleshooting/docker-issues/#solutions_4","title":"Solutions","text":"<p>Solution 1: Check memory usage</p> <pre><code># 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</code></pre> <p>Solution 2: Increase Node.js heap size</p> <p>In <code>docker-compose.yml</code>:</p> <pre><code>api:\n environment:\n - NODE_OPTIONS=--max-old-space-size=4096 # 4GB heap\n</code></pre> <p>Or in <code>api/package.json</code>:</p> <pre><code>{\n \"scripts\": {\n \"start\": \"node --max-old-space-size=4096 dist/server.js\"\n }\n}\n</code></pre> <p>Solution 3: Increase container memory limit</p> <pre><code>api:\n deploy:\n resources:\n limits:\n memory: 4G # Increase from 2G\n reservations:\n memory: 2G\n</code></pre> <p>Solution 4: Find memory leak</p> <pre><code># 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</code></pre> <p>Solution 5: Reduce memory usage</p> <pre><code>// 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</code></pre>"},{"location":"v2/troubleshooting/docker-issues/#prevention_4","title":"Prevention","text":"<ul> <li>Monitor memory - Alert on high usage</li> <li>Generous limits - Set limits higher than expected usage</li> <li>Memory profiling - Regular memory audits</li> <li>Optimize queries - Reduce data fetched</li> </ul>"},{"location":"v2/troubleshooting/docker-issues/#application-errors","title":"Application Errors","text":"<p>Severity: \ud83d\udd34 Critical</p>"},{"location":"v2/troubleshooting/docker-issues/#symptoms_5","title":"Symptoms","text":"<p>Container exits immediately:</p> <pre><code>api-1 exited with code 1\n</code></pre> <p>Logs show:</p> <pre><code>Error: Cannot find module 'express'\n</code></pre> <p>Or:</p> <pre><code>SyntaxError: Unexpected token 'export'\n</code></pre>"},{"location":"v2/troubleshooting/docker-issues/#common-causes_5","title":"Common Causes","text":"<ol> <li>Missing dependencies - npm install not run</li> <li>Build not run - TypeScript not compiled</li> <li>Syntax error - Code has errors</li> <li>Wrong Node version - Incompatible Node.js version</li> </ol>"},{"location":"v2/troubleshooting/docker-issues/#solutions_5","title":"Solutions","text":"<p>Solution 1: Rebuild container</p> <pre><code># 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</code></pre> <p>Solution 2: Check dependencies</p> <pre><code># 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</code></pre> <p>Solution 3: Verify build</p> <pre><code># 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</code></pre> <p>Solution 4: Check Node version</p> <pre><code># 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</code></pre> <p>Solution 5: Test locally</p> <pre><code># 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</code></pre>"},{"location":"v2/troubleshooting/docker-issues/#prevention_5","title":"Prevention","text":"<ul> <li>Multi-stage builds - Separate build and runtime</li> <li>Lock files - Commit package-lock.json</li> <li>CI/CD - Automated build testing</li> <li>Version pinning - Pin Node.js version</li> </ul>"},{"location":"v2/troubleshooting/docker-issues/#database-connection-failures","title":"Database Connection Failures","text":"<p>Severity: \ud83d\udd34 Critical</p>"},{"location":"v2/troubleshooting/docker-issues/#symptoms_6","title":"Symptoms","text":"<p>API logs show:</p> <pre><code>Error: Can't reach database server at `v2-postgres:5432`\nError: connect ECONNREFUSED 172.18.0.2:5432\n</code></pre> <p>Container restarts repeatedly.</p>"},{"location":"v2/troubleshooting/docker-issues/#common-causes_6","title":"Common Causes","text":"<ol> <li>Database not ready - API started before database</li> <li>Wrong host - Incorrect database hostname</li> <li>Network issue - Containers on different networks</li> <li>Database crashed - PostgreSQL container down</li> </ol>"},{"location":"v2/troubleshooting/docker-issues/#solutions_6","title":"Solutions","text":"<p>Solution 1: Check database status</p> <pre><code># 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</code></pre> <p>Solution 2: Verify DATABASE_URL</p> <pre><code># 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</code></pre> <p>Solution 3: Test database connection</p> <pre><code># 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</code></pre> <p>Solution 4: Check Docker network</p> <pre><code># 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</code></pre> <p>Solution 5: Use depends_on with health check</p> <p>In <code>docker-compose.yml</code>:</p> <pre><code>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</code></pre>"},{"location":"v2/troubleshooting/docker-issues/#prevention_6","title":"Prevention","text":"<ul> <li>Health checks - Wait for database to be ready</li> <li>Retry logic - Retry connection on startup</li> <li>Connection pooling - Handle connection failures gracefully</li> <li>Monitoring - Alert on connection failures</li> </ul>"},{"location":"v2/troubleshooting/docker-issues/#networking-issues","title":"Networking Issues","text":""},{"location":"v2/troubleshooting/docker-issues/#containers-cant-communicate","title":"Containers Can't Communicate","text":"<p>Severity: \ud83d\udd34 Critical</p>"},{"location":"v2/troubleshooting/docker-issues/#symptoms_7","title":"Symptoms","text":"<pre><code>Error: getaddrinfo ENOTFOUND v2-postgres\n</code></pre> <p>Or:</p> <pre><code>Error: connect EHOSTUNREACH 172.18.0.2:5432\n</code></pre> <p>Containers can't ping each other.</p>"},{"location":"v2/troubleshooting/docker-issues/#common-causes_7","title":"Common Causes","text":"<ol> <li>Different networks - Containers on separate Docker networks</li> <li>Wrong hostname - Using IP instead of container name</li> <li>Firewall - Host firewall blocking</li> <li>DNS issue - Docker DNS not working</li> </ol>"},{"location":"v2/troubleshooting/docker-issues/#solutions_7","title":"Solutions","text":"<p>Solution 1: Verify same network</p> <pre><code># 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</code></pre> <p>Solution 2: Use container names</p> <pre><code># 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</code></pre> <p>Solution 3: Test connectivity</p> <pre><code># 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</code></pre> <p>Solution 4: Recreate network</p> <pre><code># 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</code></pre> <p>Solution 5: Check firewall</p> <pre><code># 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</code></pre>"},{"location":"v2/troubleshooting/docker-issues/#prevention_7","title":"Prevention","text":"<ul> <li>Use service names - Never hardcode IPs</li> <li>Single network - All services on same network</li> <li>Docker DNS - Rely on Docker's built-in DNS</li> <li>Health checks - Verify connectivity on startup</li> </ul>"},{"location":"v2/troubleshooting/docker-issues/#port-not-accessible-from-host","title":"Port Not Accessible from Host","text":"<p>Severity: \ud83d\udfe0 High</p>"},{"location":"v2/troubleshooting/docker-issues/#symptoms_8","title":"Symptoms","text":"<p>From host:</p> <pre><code>curl http://localhost:4000/api/health\n# curl: (7) Failed to connect to localhost port 4000: Connection refused\n</code></pre> <p>But from inside container:</p> <pre><code>docker compose exec api curl http://localhost:4000/api/health\n# {\"status\":\"healthy\"}\n</code></pre>"},{"location":"v2/troubleshooting/docker-issues/#common-causes_8","title":"Common Causes","text":"<ol> <li>Port not published - Missing <code>ports:</code> in docker-compose.yml</li> <li>Bound to 127.0.0.1 - Only listening on localhost inside container</li> <li>Firewall blocking - Host firewall blocking port</li> <li>Wrong port - Trying different port than published</li> </ol>"},{"location":"v2/troubleshooting/docker-issues/#solutions_8","title":"Solutions","text":"<p>Solution 1: Check port publishing</p> <p>In <code>docker-compose.yml</code>:</p> <pre><code>api:\n ports:\n - \"4000:4000\" # host:container\n</code></pre> <p>Verify:</p> <pre><code>docker compose ps api\n\n# Should show:\n# PORTS: 0.0.0.0:4000->4000/tcp\n</code></pre> <p>Solution 2: Bind to 0.0.0.0</p> <p>In <code>api/src/server.ts</code>:</p> <pre><code>// 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</code></pre> <p>Solution 3: Check firewall</p> <pre><code># 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</code></pre> <p>Solution 4: Verify correct port</p> <pre><code># Check what ports are actually listening\ndocker compose exec api netstat -tuln\n\n# Should show:\n# tcp6 0 0 :::4000 :::* LISTEN\n</code></pre> <p>Solution 5: Restart with port forwarding</p> <pre><code># 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</code></pre>"},{"location":"v2/troubleshooting/docker-issues/#prevention_8","title":"Prevention","text":"<ul> <li>Always publish ports - In docker-compose.yml</li> <li>Bind to 0.0.0.0 - Not 127.0.0.1</li> <li>Test from host - Verify accessibility</li> <li>Document ports - Keep port reference updated</li> </ul>"},{"location":"v2/troubleshooting/docker-issues/#dns-resolution-failures","title":"DNS Resolution Failures","text":"<p>Severity: \ud83d\udfe0 High</p>"},{"location":"v2/troubleshooting/docker-issues/#symptoms_9","title":"Symptoms","text":"<pre><code>Error: getaddrinfo ENOTFOUND smtp.gmail.com\n</code></pre> <p>Or:</p> <pre><code>Error: getaddrinfo EAI_AGAIN api.represent.org\n</code></pre> <p>Container can't resolve external hostnames.</p>"},{"location":"v2/troubleshooting/docker-issues/#common-causes_9","title":"Common Causes","text":"<ol> <li>Docker DNS issue - Docker DNS not working</li> <li>No internet - Container has no internet access</li> <li>Firewall blocking DNS - Port 53 blocked</li> <li>Wrong DNS servers - Using invalid DNS servers</li> </ol>"},{"location":"v2/troubleshooting/docker-issues/#solutions_9","title":"Solutions","text":"<p>Solution 1: Test DNS resolution</p> <pre><code># From inside container\ndocker compose exec api nslookup google.com\n\n# Should return IP address\n# If not, DNS is broken\n</code></pre> <p>Solution 2: Check Docker DNS</p> <pre><code># 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</code></pre> <p>Solution 3: Use custom DNS servers</p> <p>In <code>docker-compose.yml</code>:</p> <pre><code>api:\n dns:\n - 8.8.8.8 # Google DNS\n - 8.8.4.4\n</code></pre> <p>Or in <code>/etc/docker/daemon.json</code>:</p> <pre><code>{\n \"dns\": [\"8.8.8.8\", \"8.8.4.4\"]\n}\n</code></pre> <p>Then restart Docker:</p> <pre><code>sudo systemctl restart docker\n</code></pre> <p>Solution 4: Check internet connectivity</p> <pre><code># 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</code></pre> <p>Solution 5: Restart Docker daemon</p> <pre><code># Sometimes Docker DNS gets stuck\nsudo systemctl restart docker\n\n# Then restart containers\ndocker compose down\ndocker compose up -d\n</code></pre>"},{"location":"v2/troubleshooting/docker-issues/#prevention_9","title":"Prevention","text":"<ul> <li>Reliable DNS - Use public DNS servers as backup</li> <li>Monitor connectivity - Alert on DNS failures</li> <li>Health checks - Include external connectivity checks</li> <li>Retry logic - Handle transient DNS failures</li> </ul>"},{"location":"v2/troubleshooting/docker-issues/#volume-issues","title":"Volume Issues","text":""},{"location":"v2/troubleshooting/docker-issues/#permission-denied","title":"Permission Denied","text":"<p>Severity: \ud83d\udd34 Critical</p>"},{"location":"v2/troubleshooting/docker-issues/#symptoms_10","title":"Symptoms","text":"<pre><code>Error: EACCES: permission denied, open '/app/uploads/image.jpg'\n</code></pre> <p>Or:</p> <pre><code>Error: EACCES: permission denied, mkdir '/media/local/inbox'\n</code></pre> <p>File operations fail inside container.</p>"},{"location":"v2/troubleshooting/docker-issues/#common-causes_10","title":"Common Causes","text":"<ol> <li>Wrong ownership - Host directory owned by different user</li> <li>Wrong permissions - Directory not writable</li> <li>SELinux - Linux security policy blocking</li> <li>Read-only mount - Volume mounted as read-only</li> </ol>"},{"location":"v2/troubleshooting/docker-issues/#solutions_10","title":"Solutions","text":"<p>Solution 1: Check ownership</p> <pre><code># 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</code></pre> <p>Solution 2: Fix permissions</p> <pre><code># Make writable\nchmod -R 755 uploads/\n\n# Or more permissive (dev only)\nchmod -R 777 uploads/\n</code></pre> <p>Solution 3: Check mount mode</p> <p>In <code>docker-compose.yml</code>:</p> <pre><code>api:\n volumes:\n - ./uploads:/app/uploads:rw # Read-write\n # Not:\n # - ./uploads:/app/uploads:ro # Read-only\n</code></pre> <p>Solution 4: SELinux labels</p> <pre><code># 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</code></pre> <p>Solution 5: Run as root (not recommended)</p> <pre><code># In docker-compose.yml (last resort)\napi:\n user: \"0:0\" # Run as root\n</code></pre>"},{"location":"v2/troubleshooting/docker-issues/#prevention_10","title":"Prevention","text":"<ul> <li>Set permissions early - In setup script</li> <li>Match UIDs - Container user matches host user</li> <li>SELinux-aware - Use :z flag on volumes</li> <li>Document requirements - List permission requirements</li> </ul>"},{"location":"v2/troubleshooting/docker-issues/#volume-not-mounted","title":"Volume Not Mounted","text":"<p>Severity: \ud83d\udfe0 High</p>"},{"location":"v2/troubleshooting/docker-issues/#symptoms_11","title":"Symptoms","text":"<p>Container can't see files that exist on host.</p> <pre><code># On host\nls uploads/\n# image.jpg video.mp4\n\n# In container\ndocker compose exec api ls /app/uploads/\n# (empty)\n</code></pre>"},{"location":"v2/troubleshooting/docker-issues/#common-causes_11","title":"Common Causes","text":"<ol> <li>Wrong path - Volume path incorrect</li> <li>Typo - Syntax error in docker-compose.yml</li> <li>Not mounted - Volume mount missing</li> <li>Cached old config - Using old container</li> </ol>"},{"location":"v2/troubleshooting/docker-issues/#solutions_11","title":"Solutions","text":"<p>Solution 1: Verify volume configuration</p> <p>In <code>docker-compose.yml</code>:</p> <pre><code>api:\n volumes:\n - ./uploads:/app/uploads # host:container\n</code></pre> <p>Solution 2: Check mounts in running container</p> <pre><code># 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</code></pre> <p>Solution 3: Recreate container</p> <pre><code># 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</code></pre> <p>Solution 4: Use absolute path</p> <pre><code># Sometimes relative paths don't work\napi:\n volumes:\n - /home/user/changemaker.lite/uploads:/app/uploads\n</code></pre> <p>Solution 5: Check Docker Compose version</p> <pre><code># Check version\ndocker compose version\n\n# Should be v2+\n# If v1, syntax might differ\n</code></pre>"},{"location":"v2/troubleshooting/docker-issues/#prevention_11","title":"Prevention","text":"<ul> <li>Test mounts - Verify after container start</li> <li>Use relative paths - Start with <code>./</code></li> <li>Documentation - Document all volume mounts</li> <li>Health checks - Verify critical files exist</li> </ul>"},{"location":"v2/troubleshooting/docker-issues/#data-persistence-problems","title":"Data Persistence Problems","text":"<p>Severity: \ud83d\udd34 Critical</p>"},{"location":"v2/troubleshooting/docker-issues/#symptoms_12","title":"Symptoms","text":"<p>Data disappears after <code>docker compose down</code>:</p> <ul> <li>Database data lost</li> <li>Uploaded files missing</li> <li>Configuration reset</li> </ul>"},{"location":"v2/troubleshooting/docker-issues/#common-causes_12","title":"Common Causes","text":"<ol> <li>Using containers, not volumes - Data stored in container filesystem</li> <li>Anonymous volumes - Volume not named or bound</li> <li>Deleting volumes - <code>docker compose down -v</code> removes volumes</li> <li>Wrong volume type - tmpfs instead of volume</li> </ol>"},{"location":"v2/troubleshooting/docker-issues/#solutions_12","title":"Solutions","text":"<p>Solution 1: Use named volumes</p> <p>In <code>docker-compose.yml</code>:</p> <pre><code>v2-postgres:\n volumes:\n - postgres-data:/var/lib/postgresql/data # Named volume\n\nvolumes:\n postgres-data: # Declare named volume\n</code></pre> <p>Solution 2: Use bind mounts</p> <pre><code>v2-postgres:\n volumes:\n - ./data/postgres:/var/lib/postgresql/data # Bind to host directory\n</code></pre> <p>Solution 3: Don't use -v flag</p> <pre><code># Wrong - deletes volumes\ndocker compose down -v\n\n# Right - keeps volumes\ndocker compose down\n</code></pre> <p>Solution 4: Check volume exists</p> <pre><code># 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</code></pre> <p>Solution 5: Backup before down</p> <pre><code># 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</code></pre>"},{"location":"v2/troubleshooting/docker-issues/#prevention_12","title":"Prevention","text":"<ul> <li>Named volumes - For all persistent data</li> <li>Regular backups - Automated backup script</li> <li>Never use -v - Unless intentionally resetting</li> <li>Documentation - Document what data persists where</li> </ul>"},{"location":"v2/troubleshooting/docker-issues/#performance-issues","title":"Performance Issues","text":""},{"location":"v2/troubleshooting/docker-issues/#slow-container-startup","title":"Slow Container Startup","text":"<p>Severity: \ud83d\udfe1 Medium</p>"},{"location":"v2/troubleshooting/docker-issues/#symptoms_13","title":"Symptoms","text":"<p>Container takes minutes to start:</p> <pre><code>docker compose up -d api\n# Creating api ... (2 minutes)\n# Creating api ... done\n</code></pre>"},{"location":"v2/troubleshooting/docker-issues/#common-causes_13","title":"Common Causes","text":"<ol> <li>Large image - Downloading/extracting large image</li> <li>Many dependencies - npm install taking long</li> <li>Health check delay - Waiting for health checks</li> <li>Slow disk - I/O bottleneck</li> </ol>"},{"location":"v2/troubleshooting/docker-issues/#solutions_13","title":"Solutions","text":"<p>Solution 1: Use pre-built image</p> <pre><code># 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</code></pre> <p>Solution 2: Layer caching</p> <pre><code># 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</code></pre> <p>Solution 3: Multi-stage builds</p> <pre><code># 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</code></pre> <p>Solution 4: Increase Docker resources</p> <p>In Docker Desktop settings:</p> <ul> <li>CPU: 4+ cores</li> <li>Memory: 8GB+</li> <li>Disk: Fast SSD</li> </ul> <p>Solution 5: Parallel builds</p> <pre><code># Build all services in parallel\ndocker compose build --parallel\n</code></pre>"},{"location":"v2/troubleshooting/docker-issues/#prevention_13","title":"Prevention","text":"<ul> <li>Optimize Dockerfile - Layer caching, multi-stage</li> <li>Small base images - Alpine instead of full images</li> <li>Registry caching - Pull from registry instead of building</li> <li>Resource allocation - Adequate CPU/memory for Docker</li> </ul>"},{"location":"v2/troubleshooting/docker-issues/#high-cpu-usage","title":"High CPU Usage","text":"<p>Severity: \ud83d\udfe0 High</p>"},{"location":"v2/troubleshooting/docker-issues/#symptoms_14","title":"Symptoms","text":"<pre><code>docker stats\n# CONTAINER CPU %\n# api 95%\n</code></pre> <p>Container consuming excessive CPU.</p>"},{"location":"v2/troubleshooting/docker-issues/#common-causes_14","title":"Common Causes","text":"<ol> <li>Infinite loop - Bug causing tight loop</li> <li>Heavy computation - Processing large dataset</li> <li>Too many workers - Worker threads maxed out</li> <li>Memory thrashing - Swapping due to low memory</li> </ol>"},{"location":"v2/troubleshooting/docker-issues/#solutions_14","title":"Solutions","text":"<p>Solution 1: Identify process</p> <pre><code># Top inside container\ndocker compose exec api top\n\n# Shows process using CPU\n</code></pre> <p>Solution 2: Check for loops</p> <pre><code># View logs for repeated messages\ndocker compose logs api | tail -100\n\n# Restart if stuck\ndocker compose restart api\n</code></pre> <p>Solution 3: Limit worker threads</p> <pre><code>// 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</code></pre> <p>Solution 4: Set CPU limits</p> <pre><code>api:\n deploy:\n resources:\n limits:\n cpus: '2.0' # Max 2 CPUs\n</code></pre> <p>Solution 5: Profile application</p> <pre><code># 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</code></pre>"},{"location":"v2/troubleshooting/docker-issues/#prevention_14","title":"Prevention","text":"<ul> <li>Monitor CPU - Alert on high usage</li> <li>Rate limiting - Limit request rate</li> <li>Queue management - Control worker concurrency</li> <li>Performance testing - Load test regularly</li> </ul>"},{"location":"v2/troubleshooting/docker-issues/#high-memory-usage","title":"High Memory Usage","text":"<p>Severity: \ud83d\udfe0 High</p>"},{"location":"v2/troubleshooting/docker-issues/#symptoms_15","title":"Symptoms","text":"<pre><code>docker stats\n# CONTAINER MEM USAGE / LIMIT\n# api 3.8GiB / 4GiB\n</code></pre> <p>Memory usage keeps increasing.</p>"},{"location":"v2/troubleshooting/docker-issues/#common-causes_15","title":"Common Causes","text":"<ol> <li>Memory leak - Not releasing memory</li> <li>Large cache - Caching too much data</li> <li>Database connections - Too many open connections</li> <li>Large response bodies - Sending huge payloads</li> </ol>"},{"location":"v2/troubleshooting/docker-issues/#solutions_15","title":"Solutions","text":"<p>Solution 1: Identify memory usage</p> <pre><code># 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</code></pre> <p>Solution 2: Restart to free memory</p> <pre><code># Temporary fix\ndocker compose restart api\n\n# Memory should drop\ndocker stats api\n</code></pre> <p>Solution 3: Reduce cache size</p> <pre><code>// 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</code></pre> <p>Solution 4: Set memory limit</p> <pre><code>api:\n deploy:\n resources:\n limits:\n memory: 2G # Hard limit\n reservations:\n memory: 1G # Reserved amount\n</code></pre> <p>Solution 5: Find memory leak</p> <pre><code># 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</code></pre>"},{"location":"v2/troubleshooting/docker-issues/#prevention_15","title":"Prevention","text":"<ul> <li>Monitor memory - Alert on high usage</li> <li>Memory limits - Prevent runaway processes</li> <li>Regular restarts - Restart daily if leaking</li> <li>Memory profiling - Profile in staging</li> </ul>"},{"location":"v2/troubleshooting/docker-issues/#useful-commands","title":"Useful Commands","text":""},{"location":"v2/troubleshooting/docker-issues/#viewing-logs","title":"Viewing Logs","text":"<pre><code># 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</code></pre>"},{"location":"v2/troubleshooting/docker-issues/#executing-commands","title":"Executing Commands","text":"<pre><code># 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</code></pre>"},{"location":"v2/troubleshooting/docker-issues/#inspecting-containers","title":"Inspecting Containers","text":"<pre><code># 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</code></pre>"},{"location":"v2/troubleshooting/docker-issues/#container-management","title":"Container Management","text":"<pre><code># 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</code></pre>"},{"location":"v2/troubleshooting/docker-issues/#rebuilding","title":"Rebuilding","text":"<pre><code># 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</code></pre>"},{"location":"v2/troubleshooting/docker-issues/#log-analysis","title":"Log Analysis","text":""},{"location":"v2/troubleshooting/docker-issues/#reading-container-logs","title":"Reading Container Logs","text":"<p>Logs follow this pattern:</p> <pre><code>[timestamp] [level] [message]\n2026-02-13T10:30:00.000Z INFO Server started on port 4000\n</code></pre>"},{"location":"v2/troubleshooting/docker-issues/#common-log-patterns","title":"Common Log Patterns","text":"<p>Successful startup:</p> <pre><code>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</code></pre> <p>Database connection error:</p> <pre><code>INFO Connecting to database...\nERROR Can't reach database server at `v2-postgres:5432`\nERROR Retrying in 5 seconds...\n</code></pre> <p>Missing environment variable:</p> <pre><code>ERROR Environment validation failed:\nERROR SMTP_HOST is required\nERROR JWT_ACCESS_SECRET is required\n</code></pre> <p>Health check failure:</p> <pre><code>WARN Health check failed: Database not connected\n</code></pre>"},{"location":"v2/troubleshooting/docker-issues/#filtering-logs","title":"Filtering Logs","text":"<pre><code># 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</code></pre>"},{"location":"v2/troubleshooting/docker-issues/#cleanup-commands","title":"Cleanup Commands","text":""},{"location":"v2/troubleshooting/docker-issues/#remove-stopped-containers","title":"Remove Stopped Containers","text":"<pre><code># 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</code></pre>"},{"location":"v2/troubleshooting/docker-issues/#remove-images","title":"Remove Images","text":"<pre><code># 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</code></pre>"},{"location":"v2/troubleshooting/docker-issues/#remove-volumes","title":"Remove Volumes","text":"<pre><code># \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</code></pre>"},{"location":"v2/troubleshooting/docker-issues/#remove-networks","title":"Remove Networks","text":"<pre><code># Remove project network (containers must be stopped first)\ndocker network rm changemaker-lite\n\n# Remove unused networks\ndocker network prune\n</code></pre>"},{"location":"v2/troubleshooting/docker-issues/#full-cleanup","title":"Full Cleanup","text":"<pre><code># \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</code></pre>"},{"location":"v2/troubleshooting/docker-issues/#safe-cleanup","title":"Safe Cleanup","text":"<pre><code># 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</code></pre>"},{"location":"v2/troubleshooting/docker-issues/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/troubleshooting/docker-issues/#docker-documentation","title":"Docker Documentation","text":"<ul> <li>Docker Issues - This guide</li> <li>Installation Guide - Initial setup</li> <li>Architecture Overview - System design</li> </ul>"},{"location":"v2/troubleshooting/docker-issues/#other-troubleshooting","title":"Other Troubleshooting","text":"<ul> <li>Common Errors - General errors</li> <li>Database Issues - PostgreSQL problems</li> <li>Monitoring Issues - Observability problems</li> </ul>"},{"location":"v2/troubleshooting/docker-issues/#docker-resources","title":"Docker Resources","text":"<ul> <li>Docker Compose Reference</li> <li>Dockerfile Best Practices</li> <li>Docker Networking</li> </ul> <p>Last Updated: February 2026 Version: V2.0 Status: Complete</p>"},{"location":"v2/troubleshooting/email-issues/","title":"Email and SMTP Issues","text":"<p>This guide covers email sending, SMTP configuration, and template-related problems in Changemaker Lite V2.</p>"},{"location":"v2/troubleshooting/email-issues/#overview","title":"Overview","text":""},{"location":"v2/troubleshooting/email-issues/#email-system-architecture","title":"Email System Architecture","text":"<p>Changemaker Lite V2 has dual email systems:</p> <ol> <li>Transactional Emails (BullMQ + Nodemailer)</li> <li>Campaign advocacy emails</li> <li>Shift confirmation emails</li> <li>Response verification emails</li> <li> <p>System notifications</p> </li> <li> <p>Newsletter Emails (Listmonk)</p> </li> <li>Marketing campaigns</li> <li>Newsletter broadcasts</li> <li>Subscriber management</li> </ol>"},{"location":"v2/troubleshooting/email-issues/#email-flow","title":"Email Flow","text":"<pre><code>User Action \u2192 Email Service \u2192 BullMQ Queue \u2192 Worker \u2192 SMTP Server \u2192 Recipient\n</code></pre>"},{"location":"v2/troubleshooting/email-issues/#key-components","title":"Key Components","text":"<ul> <li>BullMQ - Job queue for async email sending</li> <li>Nodemailer - SMTP client library</li> <li>Redis - Queue backend</li> <li>MailHog - Development email capture (test mode)</li> <li>Listmonk - Newsletter platform (optional)</li> </ul>"},{"location":"v2/troubleshooting/email-issues/#smtp-configuration","title":"SMTP Configuration","text":""},{"location":"v2/troubleshooting/email-issues/#connection-refused","title":"Connection Refused","text":"<p>Severity: \ud83d\udd34 Critical</p>"},{"location":"v2/troubleshooting/email-issues/#symptoms","title":"Symptoms","text":"<p>API logs: <pre><code>Error: Connection timeout\nError: connect ECONNREFUSED smtp.gmail.com:587\nError: Invalid login: 535-5.7.8 Username and Password not accepted\n</code></pre></p> <p>Emails not sending.</p>"},{"location":"v2/troubleshooting/email-issues/#common-causes","title":"Common Causes","text":"<ol> <li>Wrong SMTP host - Incorrect hostname</li> <li>Port blocked - Firewall blocking port 587/465</li> <li>Wrong credentials - Invalid username/password</li> <li>TLS/SSL mismatch - Wrong secure setting</li> </ol>"},{"location":"v2/troubleshooting/email-issues/#solutions","title":"Solutions","text":"<p>Solution 1: Test SMTP connection</p> <pre><code># 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</code></pre> <p>Solution 2: Verify SMTP configuration</p> <p>In <code>.env</code>:</p> <pre><code># 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</code></pre> <p>Solution 3: Use test mode</p> <pre><code># 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</code></pre> <p>Solution 4: Test email sending</p> <pre><code># 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</code></pre> <p>Solution 5: Gmail app password</p> <p>For Gmail (required if 2FA enabled):</p> <ol> <li>Go to https://myaccount.google.com/apppasswords</li> <li>Select app: Mail</li> <li>Select device: Other (Changemaker Lite)</li> <li>Click Generate</li> <li>Copy 16-character password</li> <li>Use in SMTP_PASS (no spaces)</li> </ol>"},{"location":"v2/troubleshooting/email-issues/#prevention","title":"Prevention","text":"<ul> <li>Test mode for dev - Use MailHog locally</li> <li>Secure credentials - Use app passwords, not real passwords</li> <li>Environment-specific - Different SMTP per environment</li> <li>Health checks - Test SMTP on API startup</li> </ul>"},{"location":"v2/troubleshooting/email-issues/#authentication-failed","title":"Authentication Failed","text":"<p>Severity: \ud83d\udd34 Critical</p>"},{"location":"v2/troubleshooting/email-issues/#symptoms_1","title":"Symptoms","text":"<pre><code>Error: Invalid login: 535-5.7.8 Username and Password not accepted\nError: 535 Authentication failed\n</code></pre>"},{"location":"v2/troubleshooting/email-issues/#common-causes_1","title":"Common Causes","text":"<ol> <li>Wrong password - Incorrect password</li> <li>2FA enabled - Need app password</li> <li>Less secure apps - Gmail blocking</li> <li>Account locked - Too many failed attempts</li> </ol>"},{"location":"v2/troubleshooting/email-issues/#solutions_1","title":"Solutions","text":"<p>Solution 1: Verify credentials</p> <pre><code># 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</code></pre> <p>Solution 2: Enable less secure apps (Gmail)</p> <p>\u26a0\ufe0f Not recommended. Use app password instead.</p> <ol> <li>Go to https://myaccount.google.com/lesssecureapps</li> <li>Turn on \"Allow less secure apps\"</li> </ol> <p>Solution 3: Check account status</p> <ol> <li>Try logging into email account via web</li> <li>Check for security alerts</li> <li>Verify account not locked</li> </ol> <p>Solution 4: Use OAuth2 (advanced)</p> <p>For production Gmail:</p> <pre><code>// 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</code></pre>"},{"location":"v2/troubleshooting/email-issues/#prevention_1","title":"Prevention","text":"<ul> <li>App passwords - Always use app-specific passwords</li> <li>Test credentials - Verify before deploying</li> <li>Monitor failures - Alert on auth failures</li> <li>Backup SMTP - Configure fallback SMTP server</li> </ul>"},{"location":"v2/troubleshooting/email-issues/#invalid-credentials","title":"Invalid Credentials","text":"<p>Severity: \ud83d\udd34 Critical</p>"},{"location":"v2/troubleshooting/email-issues/#symptoms_2","title":"Symptoms","text":"<pre><code>Error: Invalid SMTP credentials\nError: Username and Password not accepted\n</code></pre>"},{"location":"v2/troubleshooting/email-issues/#solutions_2","title":"Solutions","text":"<p>See \"Authentication Failed\" section above.</p>"},{"location":"v2/troubleshooting/email-issues/#port-blocked","title":"Port Blocked","text":"<p>Severity: \ud83d\udfe0 High</p>"},{"location":"v2/troubleshooting/email-issues/#symptoms_3","title":"Symptoms","text":"<pre><code>Error: connect ETIMEDOUT smtp.gmail.com:587\nError: Connection timeout\n</code></pre> <p>Connection attempt hangs, then times out after 30+ seconds.</p>"},{"location":"v2/troubleshooting/email-issues/#common-causes_2","title":"Common Causes","text":"<ol> <li>Firewall blocking - Network firewall blocking port</li> <li>ISP blocking - ISP blocks port 25/587</li> <li>Docker network - Container can't reach external SMTP</li> </ol>"},{"location":"v2/troubleshooting/email-issues/#solutions_3","title":"Solutions","text":"<p>Solution 1: Test port access</p> <pre><code># From API container\ndocker compose exec api telnet smtp.gmail.com 587\n\n# If timeout, port is blocked\n</code></pre> <p>Solution 2: Try alternative port</p> <pre><code># 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</code></pre> <p>Solution 3: Check Docker network</p> <pre><code># 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</code></pre> <p>Solution 4: Use SMTP relay</p> <p>If ISP blocks SMTP, use relay service: - SendGrid - Mailgun - Amazon SES - Postmark</p> <p>Solution 5: VPN or proxy</p> <p>As last resort, route SMTP through VPN/proxy.</p>"},{"location":"v2/troubleshooting/email-issues/#prevention_2","title":"Prevention","text":"<ul> <li>Use relay services - More reliable than direct SMTP</li> <li>Multiple ports - Try 587, 465, 2525</li> <li>Test on deploy - Verify SMTP works in production</li> <li>Documentation - Document network requirements</li> </ul>"},{"location":"v2/troubleshooting/email-issues/#template-issues","title":"Template Issues","text":""},{"location":"v2/troubleshooting/email-issues/#template-not-found","title":"Template Not Found","text":"<p>Severity: \ud83d\udfe0 High</p>"},{"location":"v2/troubleshooting/email-issues/#symptoms_4","title":"Symptoms","text":"<p>API logs: <pre><code>Error: Email template not found: campaign-email\nError: ENOENT: no such file or directory, open 'templates/campaign-email.html'\n</code></pre></p>"},{"location":"v2/troubleshooting/email-issues/#common-causes_3","title":"Common Causes","text":"<ol> <li>Template file missing - File doesn't exist</li> <li>Wrong template name - Typo in name</li> <li>Wrong directory - Looking in wrong path</li> <li>Deleted template - Template was removed</li> </ol>"},{"location":"v2/troubleshooting/email-issues/#solutions_4","title":"Solutions","text":"<p>Solution 1: List available templates</p> <pre><code># 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</code></pre> <p>Solution 2: Create missing template</p> <pre><code># 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</code></pre> <p>Solution 3: Use email template system</p> <p>Navigate to <code>/app/email-templates</code>:</p> <ol> <li>Click \"Create Template\"</li> <li>Fill in details</li> <li>Design template</li> <li>Save (creates file + DB record)</li> </ol> <p>Solution 4: Check template name</p> <pre><code>// 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</code></pre> <p>Solution 5: Verify template path</p> <p>In <code>api/src/services/email.service.ts</code>:</p> <pre><code>const templatePath = path.join(__dirname, '../../templates', `${template}.html`);\n// Resolves to: api/templates/campaign-email.html\n</code></pre>"},{"location":"v2/troubleshooting/email-issues/#prevention_3","title":"Prevention","text":"<ul> <li>Seed templates - Include default templates in seed</li> <li>Template management - Use admin UI to manage</li> <li>Version control - Keep templates in git</li> <li>Validation - Check template exists before sending</li> </ul>"},{"location":"v2/troubleshooting/email-issues/#variable-not-replaced","title":"Variable Not Replaced","text":"<p>Severity: \ud83d\udfe1 Medium</p>"},{"location":"v2/troubleshooting/email-issues/#symptoms_5","title":"Symptoms","text":"<p>Email received with unreplaced placeholders:</p> <pre><code>Hello {{name}},\n\nYour campaign {{campaignName}} is ready.\n</code></pre>"},{"location":"v2/troubleshooting/email-issues/#common-causes_4","title":"Common Causes","text":"<ol> <li>Variable not provided - Missing from variables object</li> <li>Typo in variable name - Mismatch between template and code</li> <li>Wrong delimiter - Using ${} instead of {{}}</li> <li>Escaping issue - HTML entities interfering</li> </ol>"},{"location":"v2/troubleshooting/email-issues/#solutions_5","title":"Solutions","text":"<p>Solution 1: List template variables</p> <pre><code># 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</code></pre> <p>Solution 2: Provide all variables</p> <pre><code>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</code></pre> <p>Solution 3: Check variable delimiter</p> <pre><code><!-- 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</code></pre> <p>Solution 4: Test template rendering</p> <pre><code># 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</code></pre> <p>Solution 5: Use default values</p> <pre><code><!-- In template, provide fallback -->\n<h1>Hello {{name || \"Friend\"}}</h1>\n</code></pre> <p>Or in code:</p> <pre><code>const variables = {\n name: user.name || 'Friend',\n campaignName: campaign.name || 'Campaign',\n campaignUrl: campaignUrl || '#'\n};\n</code></pre>"},{"location":"v2/troubleshooting/email-issues/#prevention_4","title":"Prevention","text":"<ul> <li>Template validation - Check all variables exist</li> <li>TypeScript types - Type template variables</li> <li>Default values - Always provide defaults</li> <li>Testing - Test all templates with sample data</li> </ul>"},{"location":"v2/troubleshooting/email-issues/#syntax-errors","title":"Syntax Errors","text":"<p>Severity: \ud83d\udfe0 High</p>"},{"location":"v2/troubleshooting/email-issues/#symptoms_6","title":"Symptoms","text":"<pre><code>Error: Parse error in template at line 15\nError: Unexpected token in template\n</code></pre> <p>Email fails to send.</p>"},{"location":"v2/troubleshooting/email-issues/#common-causes_5","title":"Common Causes","text":"<ol> <li>Invalid HTML - Malformed HTML</li> <li>Unclosed tags - Missing closing tags</li> <li>Special characters - Unescaped < > &</li> <li>Handlebars syntax - Invalid {{}} usage</li> </ol>"},{"location":"v2/troubleshooting/email-issues/#solutions_6","title":"Solutions","text":"<p>Solution 1: Validate HTML</p> <pre><code># 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</code></pre> <p>Solution 2: Check common errors</p> <pre><code><!-- Unclosed tag -->\n<div>Content here\n<!-- Should be: -->\n<div>Content here</div>\n\n<!-- Unescaped characters -->\nPrice: $50 < $100\n<!-- Should be: -->\nPrice: $50 &lt; $100\n\n<!-- Invalid Handlebars -->\n{{if name}} <!-- No \"if\" helper by default -->\n<!-- Should be: -->\n{{#if name}}...{{/if}} <!-- Or don't use if -->\n</code></pre> <p>Solution 3: Escape HTML</p> <pre><code>// 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</code></pre> <p>Solution 4: Test template compilation</p> <pre><code>// 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</code></pre>"},{"location":"v2/troubleshooting/email-issues/#prevention_5","title":"Prevention","text":"<ul> <li>HTML validation - Validate before saving</li> <li>Linting - Use HTML linter in editor</li> <li>Simple templates - Keep templates simple</li> <li>Testing - Test rendering before deploying</li> </ul>"},{"location":"v2/troubleshooting/email-issues/#queue-issues","title":"Queue Issues","text":""},{"location":"v2/troubleshooting/email-issues/#queue-stuck","title":"Queue Stuck","text":"<p>Severity: \ud83d\udfe0 High</p>"},{"location":"v2/troubleshooting/email-issues/#symptoms_7","title":"Symptoms","text":"<p>Emails queued but not sending. Queue shows jobs but no progress.</p>"},{"location":"v2/troubleshooting/email-issues/#solutions_7","title":"Solutions","text":"<p>Solution 1: Check queue status</p> <pre><code># 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</code></pre> <p>Solution 2: Check worker is running</p> <pre><code># 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</code></pre> <p>Solution 3: Restart worker</p> <pre><code># Restart API (restarts worker)\ndocker compose restart api\n\n# Check worker started\ndocker compose logs api | grep \"Email worker started\"\n</code></pre> <p>Solution 4: Check Redis</p> <pre><code># 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</code></pre> <p>Solution 5: Process stuck jobs</p> <pre><code># 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</code></pre>"},{"location":"v2/troubleshooting/email-issues/#prevention_6","title":"Prevention","text":"<ul> <li>Health checks - Monitor worker health</li> <li>Auto-restart - Restart worker if stuck</li> <li>Alerting - Alert if queue backed up</li> <li>Dead letter queue - Move repeatedly failed jobs</li> </ul>"},{"location":"v2/troubleshooting/email-issues/#jobs-failing","title":"Jobs Failing","text":"<p>Severity: \ud83d\udfe0 High</p>"},{"location":"v2/troubleshooting/email-issues/#symptoms_8","title":"Symptoms","text":"<p>High failed job count. Emails not reaching recipients.</p>"},{"location":"v2/troubleshooting/email-issues/#solutions_8","title":"Solutions","text":"<p>Solution 1: View failed jobs</p> <pre><code># 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</code></pre> <p>Solution 2: Check error patterns</p> <pre><code># 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</code></pre> <p>Solution 3: Retry with fixes</p> <pre><code># 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</code></pre> <p>Solution 4: Manual intervention</p> <p>For repeatedly failing emails:</p> <ol> <li>Check email address validity</li> <li>Verify SMTP configuration</li> <li>Test with different recipient</li> <li>Check if recipient's mailbox full</li> </ol>"},{"location":"v2/troubleshooting/email-issues/#prevention_7","title":"Prevention","text":"<ul> <li>Retry logic - Auto-retry with exponential backoff</li> <li>Email validation - Validate before queuing</li> <li>Error categorization - Permanent vs transient failures</li> <li>Bounce handling - Handle bounce notifications</li> </ul>"},{"location":"v2/troubleshooting/email-issues/#delivery-issues","title":"Delivery Issues","text":""},{"location":"v2/troubleshooting/email-issues/#emails-not-arriving","title":"Emails Not Arriving","text":"<p>Severity: \ud83d\udd34 Critical</p>"},{"location":"v2/troubleshooting/email-issues/#symptoms_9","title":"Symptoms","text":"<p>Emails sent successfully (no errors) but not received.</p>"},{"location":"v2/troubleshooting/email-issues/#common-causes_6","title":"Common Causes","text":"<ol> <li>Spam folder - Filtered to spam</li> <li>Email delay - Taking long to deliver</li> <li>Email blocking - Recipient server blocking</li> <li>Wrong address - Typo in email address</li> </ol>"},{"location":"v2/troubleshooting/email-issues/#solutions_9","title":"Solutions","text":"<p>Solution 1: Check spam folder</p> <ol> <li>Check spam/junk folder</li> <li>Check promotions tab (Gmail)</li> <li>Mark as \"Not Spam\" to whitelist</li> </ol> <p>Solution 2: Check email logs</p> <pre><code># 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</code></pre> <p>Solution 3: Use MailHog to test</p> <pre><code># 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</code></pre> <p>Solution 4: Check email headers</p> <p>In MailHog or received email: 1. View full headers 2. Check \"Received\" path 3. Look for spam scores 4. Check SPF/DKIM/DMARC status</p> <p>Solution 5: Test with different address</p> <pre><code># Try sending to different email provider\n# Gmail vs Outlook vs Yahoo\n# If some work and others don't, specific provider blocking\n</code></pre>"},{"location":"v2/troubleshooting/email-issues/#prevention_8","title":"Prevention","text":"<ul> <li>Email authentication - SPF, DKIM, DMARC</li> <li>Reputation management - Maintain good sender reputation</li> <li>Bounce handling - Monitor bounces</li> <li>Testing - Regular delivery tests</li> </ul>"},{"location":"v2/troubleshooting/email-issues/#marked-as-spam","title":"Marked as Spam","text":"<p>Severity: \ud83d\udfe0 High</p>"},{"location":"v2/troubleshooting/email-issues/#symptoms_10","title":"Symptoms","text":"<p>Emails consistently go to spam folder.</p>"},{"location":"v2/troubleshooting/email-issues/#solutions_10","title":"Solutions","text":"<p>Solution 1: Configure SPF</p> <p>Add TXT record to DNS:</p> <pre><code>v=spf1 include:_spf.google.com ~all\n</code></pre> <p>Or for SendGrid:</p> <pre><code>v=spf1 include:sendgrid.net ~all\n</code></pre> <p>Solution 2: Configure DKIM</p> <ol> <li>Generate DKIM keys (via email provider)</li> <li>Add DKIM TXT record to DNS</li> <li>Enable DKIM signing in SMTP settings</li> </ol> <p>Solution 3: Configure DMARC</p> <p>Add TXT record to DNS:</p> <pre><code>v=DMARC1; p=quarantine; rua=mailto:dmarc@yourdomain.com\n</code></pre> <p>Solution 4: Improve email content</p> <ul> <li>Use plain text version alongside HTML</li> <li>Avoid spam trigger words (\"FREE\", \"CLICK HERE\", \"ACT NOW\")</li> <li>Proper from/reply-to addresses</li> <li>Unsubscribe link</li> <li>Physical address in footer</li> </ul> <p>Solution 5: Warm up IP</p> <p>If using dedicated IP: 1. Start with low volume 2. Gradually increase over weeks 3. Monitor reputation scores</p>"},{"location":"v2/troubleshooting/email-issues/#prevention_9","title":"Prevention","text":"<ul> <li>Email authentication - SPF, DKIM, DMARC mandatory</li> <li>Content quality - Professional, non-spammy content</li> <li>Reputation monitoring - Monitor sender scores</li> <li>Engagement - High engagement = good reputation</li> </ul>"},{"location":"v2/troubleshooting/email-issues/#bounce-errors","title":"Bounce Errors","text":"<p>Severity: \ud83d\udfe1 Medium</p>"},{"location":"v2/troubleshooting/email-issues/#symptoms_11","title":"Symptoms","text":"<pre><code>Email bounced: user@example.com\n554 Recipient address rejected: User unknown\n</code></pre>"},{"location":"v2/troubleshooting/email-issues/#common-causes_7","title":"Common Causes","text":"<ol> <li>Invalid address - Email doesn't exist</li> <li>Full mailbox - Recipient mailbox full</li> <li>Temporary failure - Server temporarily unavailable</li> <li>Blocked sender - Your domain/IP blocked</li> </ol>"},{"location":"v2/troubleshooting/email-issues/#solutions_11","title":"Solutions","text":"<p>Solution 1: Categorize bounces</p> <p>Hard bounces (permanent): - User unknown - Domain doesn't exist - Invalid address format</p> <p>Soft bounces (temporary): - Mailbox full - Server temporarily unavailable - Message too large</p> <p>Solution 2: Handle hard bounces</p> <pre><code># 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</code></pre> <p>Solution 3: Retry soft bounces</p> <pre><code># Retry soft bounces after delay\ncurl -X POST http://localhost:4000/api/influence/email-queue/retry-failed \\\n -H \"Authorization: Bearer YOUR_TOKEN\"\n</code></pre> <p>Solution 4: Validate emails before sending</p> <pre><code>import validator from 'validator';\n\nconst isValidEmail = validator.isEmail(email);\nif (!isValidEmail) {\n throw new Error('Invalid email address');\n}\n</code></pre>"},{"location":"v2/troubleshooting/email-issues/#prevention_10","title":"Prevention","text":"<ul> <li>Email validation - Validate before saving</li> <li>Bounce tracking - Track bounces per address</li> <li>Automatic removal - Don't send to bounced addresses</li> <li>Double opt-in - Confirm email addresses work</li> </ul>"},{"location":"v2/troubleshooting/email-issues/#listmonk-integration","title":"Listmonk Integration","text":""},{"location":"v2/troubleshooting/email-issues/#api-connection-failed","title":"API Connection Failed","text":"<p>Severity: \ud83d\udfe0 High</p>"},{"location":"v2/troubleshooting/email-issues/#symptoms_12","title":"Symptoms","text":"<pre><code>Error: Failed to connect to Listmonk API\nError: ECONNREFUSED localhost:9001\n</code></pre>"},{"location":"v2/troubleshooting/email-issues/#solutions_12","title":"Solutions","text":"<p>Solution 1: Check Listmonk is running</p> <pre><code>docker compose ps listmonk\n\n# Should show \"Up\"\n# If not:\ndocker compose up -d listmonk\n</code></pre> <p>Solution 2: Verify API credentials</p> <pre><code># Check .env\ncat .env | grep LISTMONK_\n\n# Required:\nLISTMONK_URL=http://listmonk:9001\nLISTMONK_ADMIN_USER=admin\nLISTMONK_ADMIN_PASSWORD=password\n</code></pre> <p>Solution 3: Test API connection</p> <pre><code># From API container\ndocker compose exec api curl -u admin:password http://listmonk:9001/api/health\n\n# Should return:\n# {\"data\": \"OK\"}\n</code></pre> <p>Solution 4: Check Docker network</p> <pre><code># 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</code></pre>"},{"location":"v2/troubleshooting/email-issues/#prevention_11","title":"Prevention","text":"<ul> <li>Health checks - Verify Listmonk health on API startup</li> <li>Proper credentials - Use API user (not web admin)</li> <li>Network config - Ensure same Docker network</li> <li>Error handling - Graceful degradation if Listmonk down</li> </ul>"},{"location":"v2/troubleshooting/email-issues/#sync-errors","title":"Sync Errors","text":"<p>Severity: \ud83d\udfe1 Medium</p>"},{"location":"v2/troubleshooting/email-issues/#symptoms_13","title":"Symptoms","text":"<pre><code>Error: Failed to sync subscribers to Listmonk\nError: 400 Bad Request: Invalid email format\n</code></pre>"},{"location":"v2/troubleshooting/email-issues/#solutions_13","title":"Solutions","text":"<p>Solution 1: Check sync status</p> <p>Navigate to <code>/app/listmonk</code>:</p> <ul> <li>View sync statistics</li> <li>See last sync time</li> <li>Check error count</li> </ul> <p>Solution 2: View sync logs</p> <pre><code>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</code></pre> <p>Solution 3: Manual sync</p> <pre><code># Trigger manual sync\ncurl -X POST http://localhost:4000/api/listmonk/sync \\\n -H \"Authorization: Bearer YOUR_TOKEN\"\n</code></pre> <p>Solution 4: Check subscriber data</p> <pre><code># 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</code></pre>"},{"location":"v2/troubleshooting/email-issues/#prevention_12","title":"Prevention","text":"<ul> <li>Data validation - Validate before sync</li> <li>Duplicate handling - Handle existing subscribers</li> <li>Error logging - Log sync errors</li> <li>Regular syncs - Automated periodic syncs</li> </ul>"},{"location":"v2/troubleshooting/email-issues/#performance-issues","title":"Performance Issues","text":""},{"location":"v2/troubleshooting/email-issues/#slow-email-sending","title":"Slow Email Sending","text":"<p>Severity: \ud83d\udfe1 Medium</p>"},{"location":"v2/troubleshooting/email-issues/#symptoms_14","title":"Symptoms","text":"<p>Sending emails takes several seconds each. Bulk sends very slow.</p>"},{"location":"v2/troubleshooting/email-issues/#solutions_14","title":"Solutions","text":"<p>Solution 1: Use queue system</p> <pre><code># 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</code></pre> <p>Solution 2: Increase worker concurrency</p> <p>In <code>api/src/services/email-queue.service.ts</code>:</p> <pre><code>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</code></pre> <p>Solution 3: Use batch sending</p> <p>For transactional email services:</p> <pre><code>// Some SMTP services support batch sending\n// Send 100 emails in single API call instead of 100 separate calls\n</code></pre> <p>Solution 4: Check SMTP performance</p> <pre><code># 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</code></pre> <p>Solution 5: Use email service</p> <p>For high volume, use transactional email service: - SendGrid - Mailgun - Amazon SES - Postmark</p> <p>Faster and more reliable than SMTP.</p>"},{"location":"v2/troubleshooting/email-issues/#prevention_13","title":"Prevention","text":"<ul> <li>Queue system - Never send synchronously</li> <li>Worker concurrency - Process multiple at once</li> <li>Email service - Use dedicated email service</li> <li>Rate limiting - Respect provider limits</li> </ul>"},{"location":"v2/troubleshooting/email-issues/#queue-backlog","title":"Queue Backlog","text":"<p>Severity: \ud83d\udfe1 Medium</p>"},{"location":"v2/troubleshooting/email-issues/#symptoms_15","title":"Symptoms","text":"<p>Thousands of emails waiting in queue. Taking hours to process.</p>"},{"location":"v2/troubleshooting/email-issues/#solutions_15","title":"Solutions","text":"<p>Solution 1: Increase worker count</p> <p>Start multiple API instances:</p> <pre><code># In docker-compose.yml\napi:\n deploy:\n replicas: 3 # 3 API instances\n</code></pre> <p>Each instance runs its own worker.</p> <p>Solution 2: Increase concurrency</p> <p>See \"Slow Email Sending\" section above.</p> <p>Solution 3: Pause new emails</p> <pre><code># 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</code></pre> <p>Solution 4: Clean old jobs</p> <pre><code># 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</code></pre>"},{"location":"v2/troubleshooting/email-issues/#prevention_14","title":"Prevention","text":"<ul> <li>Monitor queue size - Alert when > 1000 waiting</li> <li>Rate limiting - Don't queue faster than can process</li> <li>Capacity planning - Size workers for expected load</li> <li>Cleanup jobs - Regular cleanup of old jobs</li> </ul>"},{"location":"v2/troubleshooting/email-issues/#useful-commands","title":"Useful Commands","text":""},{"location":"v2/troubleshooting/email-issues/#testing-email","title":"Testing Email","text":"<pre><code># 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</code></pre>"},{"location":"v2/troubleshooting/email-issues/#queue-management","title":"Queue Management","text":"<pre><code># 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</code></pre>"},{"location":"v2/troubleshooting/email-issues/#listmonk-operations","title":"Listmonk Operations","text":"<pre><code># 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</code></pre>"},{"location":"v2/troubleshooting/email-issues/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/troubleshooting/email-issues/#email-documentation","title":"Email Documentation","text":"<ul> <li>Email Issues - This guide</li> <li>Email Templates Feature - Template management</li> <li>Email Queue - Queue monitoring</li> </ul>"},{"location":"v2/troubleshooting/email-issues/#other-troubleshooting","title":"Other Troubleshooting","text":"<ul> <li>Common Errors - General errors</li> <li>Performance Optimization - Email performance</li> </ul>"},{"location":"v2/troubleshooting/email-issues/#external-resources","title":"External Resources","text":"<ul> <li>Nodemailer Documentation</li> <li>BullMQ Documentation</li> <li>Listmonk Documentation</li> <li>Gmail SMTP Settings</li> <li>SPF/DKIM/DMARC Guide</li> </ul> <p>Last Updated: February 2026 Version: V2.0 Status: Complete</p>"},{"location":"v2/troubleshooting/faq/","title":"Frequently Asked Questions (FAQ)","text":"<p>Comprehensive answers to common questions about Changemaker Lite V2.</p>"},{"location":"v2/troubleshooting/faq/#general-questions","title":"General Questions","text":""},{"location":"v2/troubleshooting/faq/#what-is-changemaker-lite","title":"What is Changemaker Lite?","text":"<p>Changemaker Lite is a self-hosted political campaign platform that consolidates:</p> <ul> <li>Advocacy email campaigns - Contact elected representatives</li> <li>Geographic mapping - Location management and visualization</li> <li>Canvassing system - Door-to-door volunteer coordination</li> <li>Volunteer management - Shift scheduling and tracking</li> <li>Landing pages - Custom campaign pages with GrapesJS editor</li> <li>Newsletter platform - Listmonk integration for marketing emails</li> <li>Media library - Video management and public galleries</li> <li>Admin dashboard - Comprehensive management interface</li> </ul> <p>Key features:</p> <ul> <li>100% self-hosted (no external services required except email)</li> <li>Docker Compose deployment (single command to start)</li> <li>Full TypeScript stack (type-safe development)</li> <li>Production-ready security (JWT auth, bcrypt passwords, rate limiting)</li> <li>Monitoring included (Prometheus + Grafana)</li> <li>Canadian electoral data support (NAR format)</li> </ul>"},{"location":"v2/troubleshooting/faq/#v1-vs-v2-differences","title":"V1 vs V2 Differences","text":"Aspect V1 V2 Architecture Two separate Node apps (Influence + Map) Single unified Express API Database NocoDB REST API PostgreSQL 16 + Prisma ORM Authentication Sessions (express-session) JWT (access + refresh tokens) Frontend EJS templates React + Vite + Ant Design State Server-side Zustand (client-side) Email Bull queues BullMQ queues Monitoring Basic logging Prometheus + Grafana + Alertmanager Security Basic Production-grade (audit completed) Status Legacy (reference only) Current (active development) <p>Migration path: V1 \u2192 V2 requires data export/import. See Migration Guide.</p>"},{"location":"v2/troubleshooting/faq/#system-requirements","title":"System Requirements","text":"<p>Minimum (Development):</p> <ul> <li>CPU: 2 cores</li> <li>RAM: 4GB</li> <li>Disk: 10GB</li> <li>OS: Linux, macOS, or Windows with WSL2</li> <li>Docker: 20.10+ and Docker Compose v2+</li> </ul> <p>Recommended (Production):</p> <ul> <li>CPU: 4+ cores</li> <li>RAM: 8-16GB</li> <li>Disk: 50GB+ SSD</li> <li>OS: Ubuntu 22.04 LTS or similar</li> <li>Docker: Latest stable version</li> </ul> <p>External services (optional):</p> <ul> <li>SMTP server (for emails) - can use Gmail, SendGrid, Mailgun, etc.</li> <li>Pangolin/Cloudflare tunnel (for HTTPS) - or use your own reverse proxy</li> </ul>"},{"location":"v2/troubleshooting/faq/#browser-compatibility","title":"Browser Compatibility","text":"<p>Supported browsers:</p> <ul> <li>\u2705 Chrome 90+ (recommended)</li> <li>\u2705 Firefox 88+</li> <li>\u2705 Safari 14+</li> <li>\u2705 Edge 90+</li> <li>\u274c Internet Explorer (not supported)</li> </ul> <p>Mobile browsers:</p> <ul> <li>\u2705 Chrome on Android</li> <li>\u2705 Safari on iOS</li> <li>\u26a0\ufe0f Some features desktop-only (GrapesJS editor, map drawing)</li> </ul> <p>Required features:</p> <ul> <li>JavaScript enabled</li> <li>Local Storage enabled</li> <li>Cookies enabled (for Listmonk only)</li> <li>WebSockets supported (for real-time features)</li> </ul>"},{"location":"v2/troubleshooting/faq/#installation-setup","title":"Installation & Setup","text":""},{"location":"v2/troubleshooting/faq/#how-to-install","title":"How to Install?","text":"<p>Quick start:</p> <pre><code># 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</code></pre> <p>See Installation Guide for detailed instructions.</p>"},{"location":"v2/troubleshooting/faq/#default-credentials","title":"Default Credentials","text":"<p>Admin user (created by seed):</p> <ul> <li>Email: admin@example.com</li> <li>Password: Admin123!</li> <li>Role: SUPER_ADMIN</li> </ul> <p>\u26a0\ufe0f IMPORTANT: Change this password immediately after first login!</p> <p>Other services:</p> <ul> <li>Grafana: admin / admin</li> <li>NocoDB: Set via NC_ADMIN_EMAIL / NC_ADMIN_PASSWORD in .env</li> <li>Listmonk: Set via LISTMONK_WEB_ADMIN_USER / LISTMONK_WEB_ADMIN_PASSWORD in .env</li> </ul>"},{"location":"v2/troubleshooting/faq/#how-to-change-password","title":"How to Change Password?","text":"<p>Via Admin UI (recommended):</p> <ol> <li>Login to admin at http://localhost:3000</li> <li>Navigate to Users (/app/users)</li> <li>Click user row</li> <li>Click Edit</li> <li>Enter new password (12+ chars, uppercase, lowercase, digit)</li> <li>Save</li> </ol> <p>Via database:</p> <pre><code># 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</code></pre> <p>Password requirements:</p> <ul> <li>Minimum 12 characters</li> <li>At least 1 uppercase letter</li> <li>At least 1 lowercase letter</li> <li>At least 1 digit</li> <li>No maximum length</li> </ul>"},{"location":"v2/troubleshooting/faq/#how-to-enable-https","title":"How to Enable HTTPS?","text":"<p>Changemaker Lite doesn't include HTTPS natively. Use one of these options:</p> <p>Option 1: Pangolin Tunnel (Recommended)</p> <p>Built-in integration:</p> <ol> <li>Navigate to <code>/app/pangolin</code></li> <li>Follow setup wizard</li> <li>Configure tunnel</li> <li>Access via HTTPS URL provided by Pangolin</li> </ol> <p>See Pangolin Integration.</p> <p>Option 2: Cloudflare Tunnel</p> <ol> <li>Install cloudflared</li> <li>Configure tunnel</li> <li>Point to localhost:3000 (admin) and localhost:4000 (API)</li> </ol> <p>Option 3: Reverse Proxy</p> <p>Add nginx/Caddy in front:</p> <pre><code># 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</code></pre> <p>Option 4: Hosting Provider</p> <p>Deploy to provider with built-in HTTPS: - DigitalOcean App Platform - Heroku - Railway - Render</p>"},{"location":"v2/troubleshooting/faq/#user-management","title":"User Management","text":""},{"location":"v2/troubleshooting/faq/#how-to-create-users","title":"How to Create Users?","text":"<p>Via Admin UI (recommended):</p> <ol> <li>Navigate to <code>/app/users</code></li> <li>Click \"Create User\"</li> <li>Fill in form:</li> <li>Email (required, unique)</li> <li>Password (required, 12+ chars)</li> <li>Name (required)</li> <li>Role (default: USER)</li> <li>Click \"Create\"</li> </ol> <p>Via API:</p> <pre><code>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</code></pre> <p>Via database:</p> <pre><code># 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</code></pre>"},{"location":"v2/troubleshooting/faq/#how-to-reset-passwords","title":"How to Reset Passwords?","text":"<p>Current: V2 doesn't have password reset flow yet (planned for Phase 15).</p> <p>Workaround: Reset manually via database (see \"How to Change Password?\" above).</p> <p>Future: Will include: - Forgot password form - Email with reset link - 24-hour expiration - One-time use tokens</p>"},{"location":"v2/troubleshooting/faq/#what-are-the-user-roles","title":"What are the User Roles?","text":"Role Level Capabilities SUPER_ADMIN 5 Full access to everything (users, settings, all features) INFLUENCE_ADMIN 4 Manage campaigns, responses, email queue MAP_ADMIN 3 Manage locations, cuts, shifts, canvassing USER 2 View public content, participate in canvassing (if assigned) TEMP 1 Very limited - shift signup confirmation only <p>Permission matrix:</p> Feature SUPER_ADMIN INFLUENCE_ADMIN MAP_ADMIN USER TEMP User management \u2705 \u274c \u274c \u274c \u274c Site settings \u2705 \u274c \u274c \u274c \u274c Campaigns (admin) \u2705 \u2705 \u274c \u274c \u274c Responses (moderation) \u2705 \u2705 \u274c \u274c \u274c Email queue \u2705 \u2705 \u274c \u274c \u274c Locations (admin) \u2705 \u274c \u2705 \u274c \u274c Cuts (admin) \u2705 \u274c \u2705 \u274c \u274c Shifts (admin) \u2705 \u274c \u2705 \u274c \u274c Canvass dashboard \u2705 \u274c \u2705 \u274c \u274c View public campaigns \u2705 \u2705 \u2705 \u2705 \u274c View public map \u2705 \u2705 \u2705 \u2705 \u274c Sign up for shifts \u2705 \u2705 \u2705 \u2705 \u2705 Canvass (volunteer) \u2705 \u2705 \u2705 \u2705 \u274c <p>Login redirects:</p> <ul> <li>SUPER_ADMIN / INFLUENCE_ADMIN / MAP_ADMIN \u2192 <code>/app</code> (admin dashboard)</li> <li>USER / TEMP \u2192 <code>/volunteer</code> (volunteer portal)</li> </ul>"},{"location":"v2/troubleshooting/faq/#how-to-suspend-users","title":"How to Suspend Users?","text":"<p>Current: V2 doesn't have user suspension yet (planned for Phase 15).</p> <p>Workaround: Delete user account or change role to TEMP (limited permissions).</p> <p>Future: Will include: - Suspended flag on User model - Suspension reason tracking - Auto-logout suspended users - Reactivation workflow</p>"},{"location":"v2/troubleshooting/faq/#campaigns","title":"Campaigns","text":""},{"location":"v2/troubleshooting/faq/#how-to-create-campaign","title":"How to Create Campaign?","text":"<ol> <li>Navigate to <code>/app/influence/campaigns</code></li> <li>Click \"Create Campaign\"</li> <li>Fill in form:</li> <li>Name (required) - Campaign title</li> <li>Slug (required, unique) - URL-friendly name</li> <li>Description (optional) - Campaign details</li> <li>Email Subject (optional) - Default email subject</li> <li>Email Body (optional) - Default email template</li> <li>Active (checkbox) - Show on public site</li> <li>Allow Custom Message (checkbox) - Let users edit message</li> <li>Click \"Create\"</li> <li>Campaign now appears in admin table and public listing (if active)</li> </ol>"},{"location":"v2/troubleshooting/faq/#how-to-publish-campaign","title":"How to Publish Campaign?","text":"<ol> <li>Navigate to <code>/app/influence/campaigns</code></li> <li>Find campaign in table</li> <li>Click row to expand</li> <li>Toggle \"Active\" switch to ON</li> <li>Campaign now visible at <code>/campaigns</code> (public)</li> </ol>"},{"location":"v2/troubleshooting/faq/#how-to-track-emails","title":"How to Track Emails?","text":"<ol> <li>Navigate to <code>/app/influence/campaigns</code></li> <li>Click campaign row</li> <li>Click \"View Emails\" button</li> <li>Drawer shows:</li> <li>Total emails sent</li> <li>Email list with timestamps</li> <li>Recipient addresses</li> <li>Email status (sent/failed)</li> </ol> <p>Via Email Queue Page:</p> <ol> <li>Navigate to <code>/app/influence/email-queue</code></li> <li>View stats:</li> <li>Total emails processed</li> <li>Success/fail counts</li> <li>Queue depth</li> <li>View recent jobs</li> <li>Retry failed jobs if needed</li> </ol>"},{"location":"v2/troubleshooting/faq/#how-to-moderate-responses","title":"How to Moderate Responses?","text":"<ol> <li>Navigate to <code>/app/influence/responses</code></li> <li>Table shows all responses with:</li> <li>Participant name/email</li> <li>Campaign</li> <li>Message excerpt</li> <li>Submission timestamp</li> <li>Verification status</li> <li>Filters:</li> <li>Campaign dropdown</li> <li>Verified/unverified toggle</li> <li>Click row to view full response</li> <li>Actions:</li> <li>Verify (if unverified)</li> <li>Delete (if inappropriate)</li> </ol> <p>Response verification workflow:</p> <ol> <li>User submits response \u2192 marked unverified</li> <li>User receives verification email</li> <li>User clicks verification link \u2192 marked verified</li> <li>Only verified responses show on public response wall</li> </ol>"},{"location":"v2/troubleshooting/faq/#map-canvassing","title":"Map & Canvassing","text":""},{"location":"v2/troubleshooting/faq/#how-to-import-locations","title":"How to Import Locations?","text":"<p>Via CSV:</p> <ol> <li>Navigate to <code>/app/map/locations</code></li> <li>Click \"Import CSV\"</li> <li>Prepare CSV with columns: <pre><code>address,city,province,postalCode,notes\n123 Main St,Toronto,ON,M5H 2N2,Corner house\n456 Oak Ave,Toronto,ON,M5H 2N3,Blue door\n</code></pre></li> <li>Upload file</li> <li>Map columns (if headers don't match exactly)</li> <li>Click \"Import\"</li> <li>Locations imported, geocoding starts automatically</li> </ol> <p>Via NAR (Canadian Electoral Data):</p> <ol> <li>Obtain NAR data files (Location + Address)</li> <li>Place in <code>/data</code> directory (mapped volume)</li> <li>Navigate to <code>/app/map/locations</code></li> <li>Click \"NAR Import\" tab</li> <li>Select province</li> <li>Select dataset</li> <li>Apply filters (city, postal code, cut, residential only)</li> <li>Preview count</li> <li>Click \"Import\"</li> <li>Import processes in background (can take minutes for large files)</li> </ol> <p>See NAR Import Guide.</p>"},{"location":"v2/troubleshooting/faq/#how-to-create-cuts","title":"How to Create Cuts?","text":"<p>Via Map Drawing:</p> <ol> <li>Navigate to <code>/app/map/cuts</code></li> <li>Click \"Map Drawing\" tab</li> <li>Map shows with drawing controls</li> <li>Click \"Draw Cut\" button</li> <li>Click on map to place vertices</li> <li>Click first vertex again to close polygon (or click \"Finish\")</li> <li>Fill in form:</li> <li>Name (required)</li> <li>Description (optional)</li> <li>Color (pick color for map display)</li> <li>Click \"Save\"</li> </ol> <p>Via GeoJSON Import:</p> <ol> <li>Prepare GeoJSON file: <pre><code>{\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</code></pre></li> <li>Navigate to <code>/app/map/cuts</code></li> <li>Click \"Create Cut\"</li> <li>Paste GeoJSON in geometry field</li> <li>Fill in name/description</li> <li>Click \"Create\"</li> </ol>"},{"location":"v2/troubleshooting/faq/#how-to-organize-shifts","title":"How to Organize Shifts?","text":"<ol> <li>Navigate to <code>/app/map/shifts</code></li> <li>Click \"Create Shift\"</li> <li>Fill in form:</li> <li>Title (required) - Shift name</li> <li>Description (optional) - Shift details</li> <li>Start Time (required) - When shift starts</li> <li>End Time (required) - When shift ends</li> <li>Cut (optional) - Assign to specific cut</li> <li>Max Volunteers (optional) - Capacity limit</li> <li>Public (checkbox) - Show on public shifts page</li> <li>Click \"Create\"</li> <li>Shift appears in admin table and public listing (if public)</li> </ol> <p>Manage signups:</p> <ol> <li>Click shift row in table</li> <li>Click \"View Signups\"</li> <li>Drawer shows:</li> <li>Signup count</li> <li>List of volunteers</li> <li>Email addresses</li> <li>Actions:</li> <li>\"Email All\" - Send message to all volunteers</li> <li>Remove individual signups if needed</li> </ol>"},{"location":"v2/troubleshooting/faq/#how-to-start-canvassing","title":"How to Start Canvassing?","text":"<p>For volunteers:</p> <ol> <li>Login to volunteer portal</li> <li>Navigate to \"My Assignments\" (/volunteer/assignments)</li> <li>Find assigned shift</li> <li>Click \"Start Canvassing\"</li> <li>Full-screen map opens (/volunteer/canvass/:cutId)</li> <li>GPS tracks your location</li> <li>Map shows:</li> <li>Your current position (blue dot)</li> <li>Locations in cut (markers)</li> <li>Walking route (blue line)</li> <li>Legend (outcome colors)</li> <li>Click location marker to record visit:</li> <li>Select outcome (Home, Away, Refused, etc.)</li> <li>Add notes (optional)</li> <li>Save</li> <li>Continue until all locations visited</li> <li>Session auto-saves progress</li> </ol> <p>For admins (monitoring):</p> <ol> <li>Navigate to <code>/app/canvass/dashboard</code></li> <li>View:</li> <li>Active sessions count</li> <li>Total visits recorded</li> <li>Recent activity feed</li> <li>Cut progress (% complete)</li> <li>Leaderboard (top canvassers)</li> <li>Click activity item to see details</li> </ol> <p>See Canvassing Guide.</p>"},{"location":"v2/troubleshooting/faq/#technical-questions","title":"Technical Questions","text":""},{"location":"v2/troubleshooting/faq/#which-database","title":"Which Database?","text":"<p>PostgreSQL 16 with two ORMs:</p> <ol> <li>Prisma - Main API (Express)</li> <li>Schema: <code>api/prisma/schema.prisma</code></li> <li>Migrations: <code>api/prisma/migrations/</code></li> <li> <p>30+ models (User, Campaign, Location, etc.)</p> </li> <li> <p>Drizzle - Media API (Fastify)</p> </li> <li>Schema: <code>api/src/modules/media/db/schema.ts</code></li> <li>Tables: <code>media_videos</code>, <code>media_reactions</code>, etc.</li> </ol> <p>Connection:</p> <ul> <li>Host: v2-postgres (container) or localhost:5433 (host)</li> <li>Database: changemaker_v2</li> <li>User: changemaker</li> <li>Password: V2_POSTGRES_PASSWORD (from .env)</li> </ul> <p>Shared database: Both ORMs use same PostgreSQL database, different tables.</p>"},{"location":"v2/troubleshooting/faq/#which-orm","title":"Which ORM?","text":"<p>Prisma for main API:</p> <ul> <li>Type-safe queries</li> <li>Auto-generated client</li> <li>Migrations workflow</li> <li>Prisma Studio GUI</li> </ul> <p>Drizzle for media API:</p> <ul> <li>Lightweight</li> <li>SQL-like API</li> <li>Schema-first approach</li> <li>No migration files (push to sync)</li> </ul> <p>Why two ORMs?</p> <p>Media API was added later as separate Fastify microservice. Using Drizzle allowed faster development without modifying main Prisma schema.</p>"},{"location":"v2/troubleshooting/faq/#api-architecture","title":"API Architecture?","text":"<p>Dual API architecture:</p> <ol> <li>Express API (Main)</li> <li>Port: 4000</li> <li>Language: TypeScript</li> <li>ORM: Prisma</li> <li>Features: Auth, campaigns, locations, shifts, canvass, pages</li> <li> <p>Endpoints: <code>/api/*</code></p> </li> <li> <p>Fastify Media API (Microservice)</p> </li> <li>Port: 4100</li> <li>Language: TypeScript</li> <li>ORM: Drizzle</li> <li>Features: Video library, uploads, reactions</li> <li>Endpoints: <code>/api/media/*</code></li> </ol> <p>Shared:</p> <ul> <li>Same PostgreSQL database</li> <li>Same Redis instance</li> <li>Same Docker network</li> <li>Separate containerization (can scale independently)</li> </ul> <p>Frontend:</p> <ul> <li>React SPA (Vite)</li> <li>Port: 3000</li> <li>State: Zustand</li> <li>UI: Ant Design</li> <li>Routing: React Router v6</li> </ul>"},{"location":"v2/troubleshooting/faq/#authentication-method","title":"Authentication Method?","text":"<p>JWT-based authentication:</p> <p>Tokens:</p> <ol> <li>Access Token</li> <li>Duration: 15 minutes</li> <li>Stored: Memory (localStorage)</li> <li>Contains: userId, email, role</li> <li> <p>Used: All authenticated requests</p> </li> <li> <p>Refresh Token</p> </li> <li>Duration: 7 days</li> <li>Stored: Database + localStorage</li> <li>Used: Renew access token</li> <li>Rotation: New refresh token on each refresh</li> </ol> <p>Flow:</p> <ol> <li>Login \u2192 Returns access + refresh tokens</li> <li>Store in localStorage (Zustand persist)</li> <li>Add access token to Authorization header</li> <li>Access token expires after 15min</li> <li>Frontend auto-refreshes using refresh token</li> <li>New access + refresh tokens returned</li> <li>Continue seamlessly</li> </ol> <p>Security features:</p> <ul> <li>bcrypt password hashing (10 rounds)</li> <li>Token rotation prevents replay attacks</li> <li>Refresh tokens stored in database (can revoke)</li> <li>Rate limiting on auth endpoints (10/min)</li> <li>User enumeration prevention</li> <li>Redis authentication required</li> </ul> <p>See Authentication Flow.</p>"},{"location":"v2/troubleshooting/faq/#performance","title":"Performance","text":""},{"location":"v2/troubleshooting/faq/#how-many-users-supported","title":"How Many Users Supported?","text":"<p>Concurrent users:</p> <ul> <li>Development: 10-50 users</li> <li>Production (default config): 100-500 users</li> <li>Production (optimized): 1000+ users</li> </ul> <p>Factors:</p> <ul> <li>Database connection pool (default: 10 connections)</li> <li>API worker concurrency (default: 1 worker)</li> <li>Server resources (CPU/RAM)</li> <li>Network bandwidth</li> </ul> <p>Scaling:</p> <ul> <li>Horizontal: Run multiple API instances</li> <li>Vertical: Increase server resources</li> <li>Database: Read replicas for read-heavy loads</li> <li>Caching: Redis caching for frequently accessed data</li> </ul>"},{"location":"v2/troubleshooting/faq/#how-to-scale","title":"How to Scale?","text":"<p>Horizontal scaling (recommended):</p> <pre><code># 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</code></pre> <p>Add load balancer in front:</p> <pre><code>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</code></pre> <p>Vertical scaling:</p> <p>Increase resources:</p> <pre><code>api:\n deploy:\n resources:\n limits:\n cpus: '4.0' # More CPU\n memory: 8G # More RAM\n</code></pre> <p>Database scaling:</p> <ul> <li>Add read replicas for read-heavy queries</li> <li>Use connection pooler (PgBouncer)</li> <li>Optimize queries and indexes</li> </ul> <p>Caching:</p> <ul> <li>Redis caching for geocoding results</li> <li>Redis caching for representative lookups</li> <li>HTTP caching headers (Nginx)</li> <li>Static asset CDN</li> </ul>"},{"location":"v2/troubleshooting/faq/#database-size-limits","title":"Database Size Limits?","text":"<p>PostgreSQL:</p> <ul> <li>Maximum database size: ~32 TB (theoretical)</li> <li>Practical limit: Depends on storage and backup strategy</li> </ul> <p>Typical sizes (after 1 year):</p> <ul> <li>Small campaign: 100MB-500MB (1k locations, 10 campaigns)</li> <li>Medium campaign: 500MB-2GB (10k locations, 50 campaigns)</li> <li>Large campaign: 2GB-10GB (100k locations, 200 campaigns)</li> </ul> <p>Storage requirements:</p> <ul> <li>Database: 1-10GB</li> <li>Uploads: 5-50GB (videos)</li> <li>Backups: 2\u00d7 database size (keep multiple backups)</li> <li>Logs: 1-5GB/month</li> <li>Total: 20-100GB recommended</li> </ul> <p>Optimization:</p> <ul> <li>Regular VACUUM (auto-enabled)</li> <li>Archive old campaigns</li> <li>Delete old logs</li> <li>Compress backups</li> </ul>"},{"location":"v2/troubleshooting/faq/#security","title":"Security","text":""},{"location":"v2/troubleshooting/faq/#is-data-encrypted","title":"Is Data Encrypted?","text":"<p>At rest:</p> <ul> <li>Database: Not encrypted by default (enable PostgreSQL encryption if needed)</li> <li>Passwords: bcrypt hashed (cannot be decrypted)</li> <li>Sensitive fields: ENCRYPTION_KEY env var for encrypting secrets</li> </ul> <p>In transit:</p> <ul> <li>HTTPS: Use Pangolin/Cloudflare tunnel (encrypts all traffic)</li> <li>Database: PostgreSQL connections within Docker network (isolated)</li> <li>Redis: Authenticated (password required)</li> </ul> <p>Recommendations:</p> <ul> <li>Use HTTPS in production</li> <li>Rotate ENCRYPTION_KEY periodically</li> <li>Enable PostgreSQL SSL if database exposed</li> <li>Use strong passwords for all services</li> </ul>"},{"location":"v2/troubleshooting/faq/#password-requirements","title":"Password Requirements?","text":"<p>Enforced policy:</p> <ul> <li>Minimum 12 characters</li> <li>At least 1 uppercase letter (A-Z)</li> <li>At least 1 lowercase letter (a-z)</li> <li>At least 1 digit (0-9)</li> <li>No maximum length</li> </ul> <p>Valid examples:</p> <ul> <li><code>SecurePass123!</code></li> <li><code>MyPassword99</code></li> <li><code>Admin12345678</code></li> </ul> <p>Invalid:</p> <ul> <li><code>short</code> (too short)</li> <li><code>nouppercase123</code> (no uppercase)</li> <li><code>NOLOWERCASE123</code> (no lowercase)</li> <li><code>NoDigitsHere</code> (no digit)</li> </ul> <p>Storage:</p> <ul> <li>bcrypt hashed with salt (10 rounds)</li> <li>Hash stored in database (not plaintext)</li> <li>Cannot be decrypted (one-way hash)</li> </ul>"},{"location":"v2/troubleshooting/faq/#how-to-backup","title":"How to Backup?","text":"<p>Manual backup:</p> <pre><code># 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</code></pre> <p>What's included:</p> <ul> <li>Complete database dump (all tables)</li> <li>Uploaded files (videos, images, documents)</li> <li>Listmonk database (if enabled)</li> </ul> <p>What's NOT included:</p> <ul> <li>Docker images (rebuild from Dockerfile)</li> <li>.env file (keep separate, has secrets)</li> <li>Temporary files (logs, cache)</li> </ul> <p>Automated backups:</p> <p>Add cron job:</p> <pre><code># 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</code></pre> <p>Restore:</p> <pre><code># 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</code></pre> <p>See Backup Guide.</p>"},{"location":"v2/troubleshooting/faq/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/troubleshooting/faq/#where-are-logs","title":"Where are Logs?","text":"<p>Docker logs:</p> <pre><code># 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</code></pre> <p>Log locations inside containers:</p> <ul> <li>API: Console output (docker logs)</li> <li>PostgreSQL: Console + <code>/var/lib/postgresql/data/log/</code> (if logging enabled)</li> <li>Nginx: <code>/var/log/nginx/access.log</code>, <code>/var/log/nginx/error.log</code></li> </ul> <p>Log levels:</p> <ul> <li>ERROR: Errors requiring attention</li> <li>WARN: Warnings (not critical)</li> <li>INFO: Informational messages</li> <li>DEBUG: Debugging information (enable with LOG_LEVEL=debug)</li> </ul>"},{"location":"v2/troubleshooting/faq/#how-to-restart-services","title":"How to Restart Services?","text":"<p>Restart specific service:</p> <pre><code># Restart API\ndocker compose restart api\n\n# Restart multiple services\ndocker compose restart api admin v2-postgres\n</code></pre> <p>Restart all services:</p> <pre><code># Graceful restart (preserves data)\ndocker compose restart\n\n# Stop and start (recreates containers)\ndocker compose down\ndocker compose up -d\n</code></pre> <p>Force recreate:</p> <pre><code># Rebuild and recreate\ndocker compose up -d --build --force-recreate\n\n# Recreate specific service\ndocker compose up -d --build --force-recreate api\n</code></pre> <p>Restart single container:</p> <pre><code># Get container name\ndocker compose ps\n\n# Restart by name\ndocker restart changemaker-lite-api-1\n</code></pre>"},{"location":"v2/troubleshooting/faq/#how-to-reset-database","title":"How to Reset Database?","text":"<p>\u26a0\ufe0f WARNING: This deletes ALL data!</p> <p>Full reset:</p> <pre><code># 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</code></pre> <p>Reset specific tables:</p> <pre><code># 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</code></pre> <p>Reset without deleting volumes:</p> <pre><code># 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</code></pre>"},{"location":"v2/troubleshooting/faq/#getting-help","title":"Getting Help","text":""},{"location":"v2/troubleshooting/faq/#documentation-links","title":"Documentation Links","text":"<p>User Guides:</p> <ul> <li>Installation Guide</li> <li>User Guide</li> <li>Admin Guide</li> <li>Canvassing Guide</li> <li>NAR Import Guide</li> </ul> <p>Technical Documentation:</p> <ul> <li>Architecture Overview</li> <li>API Reference</li> <li>Database Schema</li> <li>Authentication Flow</li> </ul> <p>Troubleshooting:</p> <ul> <li>Common Errors</li> <li>Docker Issues</li> <li>Database Issues</li> <li>Auth Issues</li> <li>Email Issues</li> <li>Geocoding Issues</li> <li>Monitoring Issues</li> <li>Performance Optimization</li> </ul>"},{"location":"v2/troubleshooting/faq/#github-issues","title":"GitHub Issues","text":"<p>Before creating issue:</p> <ol> <li>Check existing issues</li> <li>Search closed issues (may already be fixed)</li> <li>Check Troubleshooting guides</li> <li>Try latest version (<code>git pull origin v2</code>)</li> </ol> <p>Creating good issues:</p> <p>Bug reports:</p> <pre><code>**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</code></pre> <p>Feature requests:</p> <pre><code>**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</code></pre>"},{"location":"v2/troubleshooting/faq/#community-support","title":"Community Support","text":"<p>Official channels:</p> <ul> <li>GitHub Issues (bugs and features)</li> <li>GitHub Discussions (questions and ideas)</li> <li>Documentation (this site)</li> </ul> <p>Response time:</p> <ul> <li>Bug reports: 1-7 days</li> <li>Feature requests: Variable (depends on priority)</li> <li>Questions: 1-3 days</li> </ul> <p>Contributing:</p> <ul> <li>Pull requests welcome</li> <li>Follow Contributing Guide</li> <li>Follow Code of Conduct</li> </ul> <p>Last Updated: February 2026 Version: V2.0 Status: Complete</p>"},{"location":"v2/troubleshooting/geocoding-issues/","title":"Geocoding and Map Issues","text":"<p>This guide covers geocoding, map display, and location-related problems in Changemaker Lite V2.</p>"},{"location":"v2/troubleshooting/geocoding-issues/#overview","title":"Overview","text":""},{"location":"v2/troubleshooting/geocoding-issues/#geocoding-system","title":"Geocoding System","text":"<p>Changemaker Lite V2 uses multi-provider geocoding with automatic fallback:</p> <ol> <li>Google Geocoding API - Most accurate, requires API key</li> <li>Mapbox Geocoding API - Good quality, requires API key</li> <li>Nominatim (OpenStreetMap) - Free, no key required</li> <li>ArcGIS Geocoding Service - Good for North America</li> <li>Photon (OpenStreetMap) - Free alternative</li> <li>HERE Geocoding API - Paid option</li> </ol>"},{"location":"v2/troubleshooting/geocoding-issues/#geocoding-queue","title":"Geocoding Queue","text":"<ul> <li>BullMQ queue - Async geocoding for bulk imports</li> <li>Rate limiting - Respects provider rate limits</li> <li>Retry logic - Auto-retry failed geocodes</li> <li>Priority - Manual geocodes prioritized over bulk</li> </ul>"},{"location":"v2/troubleshooting/geocoding-issues/#map-display","title":"Map Display","text":"<ul> <li>Leaflet.js - Open-source map library</li> <li>OpenStreetMap tiles - Free map tiles</li> <li>Circle markers - Color-coded by cut assignment</li> <li>Polygon overlays - Cut boundaries</li> <li>Geolocate - Find user's current location</li> </ul>"},{"location":"v2/troubleshooting/geocoding-issues/#geocoding-failures","title":"Geocoding Failures","text":""},{"location":"v2/troubleshooting/geocoding-issues/#address-not-found","title":"Address Not Found","text":"<p>Severity: \ud83d\udfe1 Medium</p>"},{"location":"v2/troubleshooting/geocoding-issues/#symptoms","title":"Symptoms","text":"<p>Location shows <code>null</code> latitude/longitude after geocoding attempt.</p> <p>API logs: <pre><code>WARN Geocoding failed for address: \"123 Fake St, Nowhere\": No results from any provider\n</code></pre></p>"},{"location":"v2/troubleshooting/geocoding-issues/#common-causes","title":"Common Causes","text":"<ol> <li>Invalid address - Address doesn't exist</li> <li>Typo - Misspelled street/city/postal code</li> <li>Incomplete address - Missing city or postal code</li> <li>Wrong country - Address in different country</li> <li>Rural address - Not in geocoding databases</li> </ol>"},{"location":"v2/troubleshooting/geocoding-issues/#solutions","title":"Solutions","text":"<p>Solution 1: Verify address format</p> <pre><code># 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</code></pre> <p>Solution 2: Test address manually</p> <pre><code># 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</code></pre> <p>Solution 3: Try alternative formats</p> <pre><code># 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</code></pre> <p>Solution 4: Check geocoding logs</p> <pre><code># 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</code></pre> <p>Solution 5: Manually set coordinates</p> <p>In admin UI (LocationsPage):</p> <ol> <li>Find location in table</li> <li>Click Edit</li> <li>Manually enter lat/lng (from Google Maps)</li> <li>Save</li> </ol> <p>Or via SQL:</p> <pre><code>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</code></pre>"},{"location":"v2/troubleshooting/geocoding-issues/#prevention","title":"Prevention","text":"<ul> <li>Address validation - Validate format before saving</li> <li>Postal code lookup - Use postal code if full address fails</li> <li>Manual review - Flag failed geocodes for manual review</li> <li>Alternative sources - Try multiple address formats</li> </ul>"},{"location":"v2/troubleshooting/geocoding-issues/#all-providers-failed","title":"All Providers Failed","text":"<p>Severity: \ud83d\udfe0 High</p>"},{"location":"v2/troubleshooting/geocoding-issues/#symptoms_1","title":"Symptoms","text":"<pre><code>ERROR Geocoding failed: All providers failed for address: \"123 Main St\"\n</code></pre> <p>All 6 geocoding providers returned no results or errors.</p>"},{"location":"v2/troubleshooting/geocoding-issues/#common-causes_1","title":"Common Causes","text":"<ol> <li>Network issue - Can't reach external APIs</li> <li>Rate limits - All providers rate limited</li> <li>Invalid API keys - Google/Mapbox keys invalid</li> <li>Bad address - Address truly doesn't exist</li> <li>Provider outages - Services down</li> </ol>"},{"location":"v2/troubleshooting/geocoding-issues/#solutions_1","title":"Solutions","text":"<p>Solution 1: Check network connectivity</p> <pre><code># 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</code></pre> <p>Solution 2: Test each provider manually</p> <pre><code># 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</code></pre> <p>Solution 3: Check API keys</p> <pre><code># 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</code></pre> <p>Solution 4: Check rate limits</p> <pre><code># 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</code></pre> <p>Solution 5: Wait and retry</p> <p>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)</p> <pre><code># Retry geocoding after wait\ncurl -X POST http://localhost:4000/api/map/locations/LOCATION_ID/geocode \\\n -H \"Authorization: Bearer YOUR_TOKEN\"\n</code></pre>"},{"location":"v2/troubleshooting/geocoding-issues/#prevention_1","title":"Prevention","text":"<ul> <li>API key monitoring - Alert on API key errors</li> <li>Rate limit tracking - Monitor usage against limits</li> <li>Provider rotation - Distribute load across providers</li> <li>Graceful degradation - Continue with partial results</li> </ul>"},{"location":"v2/troubleshooting/geocoding-issues/#low-confidence-results","title":"Low Confidence Results","text":"<p>Severity: \ud83d\udfe2 Low</p>"},{"location":"v2/troubleshooting/geocoding-issues/#symptoms_2","title":"Symptoms","text":"<p>Geocoding succeeds but coordinates seem wrong or imprecise.</p> <p>Example: - Address: \"123 Main Street, Toronto\" - Geocoded to: Center of Toronto (not specific address)</p>"},{"location":"v2/troubleshooting/geocoding-issues/#common-causes_2","title":"Common Causes","text":"<ol> <li>Ambiguous address - Multiple matches</li> <li>Incomplete address - Missing street number</li> <li>Rural address - Only city-level precision</li> <li>Provider limitation - Provider doesn't have precise data</li> </ol>"},{"location":"v2/troubleshooting/geocoding-issues/#solutions_2","title":"Solutions","text":"<p>Solution 1: Check geocoding confidence</p> <pre><code># 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</code></pre> <p>Solution 2: Add more detail to address</p> <pre><code># 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</code></pre> <p>Solution 3: Use postal code geocoding</p> <p>For Canadian addresses, postal code is often more accurate:</p> <pre><code># 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</code></pre> <p>Solution 4: Manually verify on map</p> <p>In LocationsPage: 1. Click location row 2. View on map 3. If wrong, manually drag marker to correct location 4. Save</p> <p>Solution 5: Flag for review</p> <pre><code># 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</code></pre>"},{"location":"v2/troubleshooting/geocoding-issues/#prevention_2","title":"Prevention","text":"<ul> <li>Confidence tracking - Store confidence score</li> <li>Manual review queue - Review low-confidence results</li> <li>Address validation - Validate format before geocoding</li> <li>Postal code priority - Use postal code when available</li> </ul>"},{"location":"v2/troubleshooting/geocoding-issues/#rate-limit-exceeded","title":"Rate Limit Exceeded","text":"<p>Severity: \ud83d\udfe1 Medium</p>"},{"location":"v2/troubleshooting/geocoding-issues/#symptoms_3","title":"Symptoms","text":"<pre><code>ERROR Geocoding rate limit exceeded for provider: google\nWARN Retrying with next provider: mapbox\n</code></pre> <p>Or:</p> <pre><code>ERROR 429 Too Many Requests from https://maps.googleapis.com/\n</code></pre>"},{"location":"v2/troubleshooting/geocoding-issues/#common-causes_3","title":"Common Causes","text":"<ol> <li>Bulk import - Geocoding thousands of addresses at once</li> <li>No API key - Free tier has lower limits</li> <li>Shared IP - Multiple users on same IP</li> <li>Testing - Repeated manual geocodes</li> </ol>"},{"location":"v2/troubleshooting/geocoding-issues/#solutions_3","title":"Solutions","text":"<p>Solution 1: Check rate limits</p> <p>Per-provider limits:</p> Provider Free Tier With API Key Nominatim 1/sec N/A Google N/A 50/sec (or paid limit) Mapbox N/A 600/min ArcGIS 1000/day Varies Photon Unlimited N/A HERE N/A Varies by plan <p>Solution 2: Use geocoding queue</p> <p>For bulk operations:</p> <pre><code># 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</code></pre> <p>Solution 3: Add API keys</p> <pre><code># In .env\nGOOGLE_GEOCODING_API_KEY=your-key-here\nMAPBOX_API_KEY=your-key-here\n\n# Restart API\ndocker compose restart api\n</code></pre> <p>Solution 4: Distribute across providers</p> <pre><code># 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</code></pre> <p>Solution 5: Wait and retry</p> <pre><code># 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</code></pre>"},{"location":"v2/troubleshooting/geocoding-issues/#prevention_3","title":"Prevention","text":"<ul> <li>API keys - Use paid tiers for higher limits</li> <li>Queue system - Respect rate limits automatically</li> <li>Provider rotation - Distribute load</li> <li>Monitor usage - Alert when approaching limits</li> </ul>"},{"location":"v2/troubleshooting/geocoding-issues/#map-display-issues","title":"Map Display Issues","text":""},{"location":"v2/troubleshooting/geocoding-issues/#map-not-loading","title":"Map Not Loading","text":"<p>Severity: \ud83d\udfe0 High</p>"},{"location":"v2/troubleshooting/geocoding-issues/#symptoms_4","title":"Symptoms","text":"<p>Map container shows blank white/gray box. No tiles loaded.</p> <p>Browser console: <pre><code>Error loading tile: https://tile.openstreetmap.org/...\nFailed to load resource: net::ERR_BLOCKED_BY_CLIENT\n</code></pre></p>"},{"location":"v2/troubleshooting/geocoding-issues/#common-causes_4","title":"Common Causes","text":"<ol> <li>Ad blocker - Blocking OSM tile requests</li> <li>Network issue - Can't reach tile server</li> <li>CSP headers - Content Security Policy blocking</li> <li>Leaflet CSS missing - Styles not imported</li> </ol>"},{"location":"v2/troubleshooting/geocoding-issues/#solutions_4","title":"Solutions","text":"<p>Solution 1: Disable ad blocker</p> <ol> <li>Disable ad blocker for your site</li> <li>Or whitelist <code>*.openstreetmap.org</code></li> <li>Refresh page</li> </ol> <p>Solution 2: Check network</p> <pre><code># 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</code></pre> <p>Solution 3: Verify Leaflet CSS</p> <p>In map component file:</p> <pre><code>// Must import Leaflet CSS\nimport 'leaflet/dist/leaflet.css';\n</code></pre> <p>Check in browser DevTools: - Elements tab \u2192 Check if <code>.leaflet-container</code> has styles - Network tab \u2192 Check if <code>leaflet.css</code> loaded</p> <p>Solution 4: Check CSP headers</p> <p>In <code>nginx/conf.d/default.conf</code>:</p> <pre><code># Allow OSM tiles\nadd_header Content-Security-Policy \"... img-src 'self' data: https://*.openstreetmap.org;\";\n</code></pre> <p>Solution 5: Try alternative tile provider</p> <pre><code>// In map component\n<TileLayer\n attribution='&copy; <a href=\"https://www.openstreetmap.org/copyright\">OpenStreetMap</a>'\n url=\"https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png\"\n // Or try Carto:\n // url=\"https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png\"\n/>\n</code></pre>"},{"location":"v2/troubleshooting/geocoding-issues/#prevention_4","title":"Prevention","text":"<ul> <li>Ad blocker warning - Detect and show warning</li> <li>Fallback tiles - Multiple tile providers</li> <li>Error boundaries - Catch map loading errors</li> <li>Clear documentation - Document ad blocker issue</li> </ul>"},{"location":"v2/troubleshooting/geocoding-issues/#markers-not-appearing","title":"Markers Not Appearing","text":"<p>Severity: \ud83d\udfe1 Medium</p>"},{"location":"v2/troubleshooting/geocoding-issues/#symptoms_5","title":"Symptoms","text":"<p>Map loads but location markers don't appear.</p>"},{"location":"v2/troubleshooting/geocoding-issues/#common-causes_5","title":"Common Causes","text":"<ol> <li>No data - No locations fetched</li> <li>Null coordinates - Locations not geocoded</li> <li>Out of bounds - Markers outside map view</li> <li>Rendering error - React component error</li> </ol>"},{"location":"v2/troubleshooting/geocoding-issues/#solutions_5","title":"Solutions","text":"<p>Solution 1: Check data loaded</p> <pre><code>// 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</code></pre> <p>Solution 2: Verify coordinates</p> <pre><code># 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</code></pre> <p>Solution 3: Zoom to markers</p> <pre><code>// 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</code></pre> <p>Solution 4: Check marker rendering</p> <pre><code>// 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</code></pre> <p>Solution 5: Check browser console</p> <p>Look for React errors: <pre><code>Warning: Each child in a list should have a unique \"key\" prop\nError: Invalid latitude/longitude\n</code></pre></p>"},{"location":"v2/troubleshooting/geocoding-issues/#prevention_5","title":"Prevention","text":"<ul> <li>Data validation - Ensure data has coordinates</li> <li>Error boundaries - Catch rendering errors</li> <li>Loading states - Show loading while fetching</li> <li>Empty states - Show message if no data</li> </ul>"},{"location":"v2/troubleshooting/geocoding-issues/#cuts-not-rendering","title":"Cuts Not Rendering","text":"<p>Severity: \ud83d\udfe1 Medium</p>"},{"location":"v2/troubleshooting/geocoding-issues/#symptoms_6","title":"Symptoms","text":"<p>Cut polygons don't appear on map.</p>"},{"location":"v2/troubleshooting/geocoding-issues/#common-causes_6","title":"Common Causes","text":"<ol> <li>Invalid GeoJSON - Malformed polygon data</li> <li>Wrong coordinate order - GeoJSON uses [lng, lat], Leaflet uses [lat, lng]</li> <li>Self-intersecting polygon - Invalid polygon geometry</li> <li>Out of bounds - Polygon outside map view</li> </ol>"},{"location":"v2/troubleshooting/geocoding-issues/#solutions_6","title":"Solutions","text":"<p>Solution 1: Validate GeoJSON</p> <pre><code># 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</code></pre> <p>Solution 2: Convert coordinates</p> <pre><code>// 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</code></pre> <p>Solution 3: Check for self-intersection</p> <pre><code>-- 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</code></pre> <p>Solution 4: Zoom to cut</p> <pre><code>// 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</code></pre> <p>Solution 5: Check Polygon component</p> <pre><code>// 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</code></pre>"},{"location":"v2/troubleshooting/geocoding-issues/#prevention_6","title":"Prevention","text":"<ul> <li>Geometry validation - Validate on save</li> <li>Drawing tools - Use validated drawing library</li> <li>Import validation - Check imported geometries</li> <li>Error handling - Gracefully handle invalid geometries</li> </ul>"},{"location":"v2/troubleshooting/geocoding-issues/#gps-not-working","title":"GPS Not Working","text":"<p>Severity: \ud83d\udfe1 Medium</p>"},{"location":"v2/troubleshooting/geocoding-issues/#symptoms_7","title":"Symptoms","text":"<p>Geolocate button doesn't work or shows error.</p> <p>Browser shows permission prompt but location never loads.</p>"},{"location":"v2/troubleshooting/geocoding-issues/#common-causes_7","title":"Common Causes","text":"<ol> <li>HTTPS required - Geolocation API requires HTTPS (or localhost)</li> <li>Permission denied - User denied location permission</li> <li>GPS unavailable - Device has no GPS</li> <li>Browser doesn't support - Old browser</li> </ol>"},{"location":"v2/troubleshooting/geocoding-issues/#solutions_7","title":"Solutions","text":"<p>Solution 1: Check HTTPS</p> <p>Geolocation API requires: - HTTPS (https://) - OR localhost (http://localhost) - OR 127.0.0.1 (http://127.0.0.1)</p> <pre><code># In production, ensure HTTPS\n# Via Pangolin tunnel or Cloudflare\n</code></pre> <p>Solution 2: Grant permission</p> <ol> <li>Click lock icon in address bar</li> <li>Location \u2192 Allow</li> <li>Refresh page</li> <li>Try geolocate again</li> </ol> <p>Solution 3: Test geolocation API</p> <pre><code>// 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</code></pre> <p>Solution 4: Increase timeout</p> <pre><code>// 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</code></pre> <p>Solution 5: Fallback to IP geolocation</p> <pre><code>// 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</code></pre>"},{"location":"v2/troubleshooting/geocoding-issues/#prevention_7","title":"Prevention","text":"<ul> <li>HTTPS in production - Use secure connection</li> <li>Permission prompts - Clear instructions</li> <li>Fallback options - IP geolocation as backup</li> <li>Error handling - User-friendly error messages</li> </ul>"},{"location":"v2/troubleshooting/geocoding-issues/#coordinate-issues","title":"Coordinate Issues","text":""},{"location":"v2/troubleshooting/geocoding-issues/#invalid-latlng","title":"Invalid Lat/Lng","text":"<p>Severity: \ud83d\udfe1 Medium</p>"},{"location":"v2/troubleshooting/geocoding-issues/#symptoms_8","title":"Symptoms","text":"<pre><code>Error: Invalid latitude/longitude values\n</code></pre> <p>Or markers appear in wrong location (ocean, wrong country).</p>"},{"location":"v2/troubleshooting/geocoding-issues/#common-causes_8","title":"Common Causes","text":"<ol> <li>Swapped coordinates - Latitude and longitude reversed</li> <li>Out of range - Latitude > 90 or Longitude > 180</li> <li>Wrong sign - Positive instead of negative (or vice versa)</li> <li>Decimal precision - Too many/few decimal places</li> </ol>"},{"location":"v2/troubleshooting/geocoding-issues/#solutions_8","title":"Solutions","text":"<p>Solution 1: Validate ranges</p> <p>Valid ranges: - Latitude: -90 to 90 - Longitude: -180 to 180</p> <pre><code># 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</code></pre> <p>Solution 2: Check coordinate order</p> <pre><code># 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</code></pre> <p>Solution 3: Verify hemisphere</p> <p>For North American locations: - Latitude: Positive (North) - Longitude: Negative (West)</p> <pre><code># If US/Canada location has positive longitude, wrong sign\nUPDATE \"Location\"\nSET longitude = longitude * -1\nWHERE country = 'Canada' AND longitude > 0;\n</code></pre> <p>Solution 4: Check decimal precision</p> <pre><code># 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</code></pre> <p>Solution 5: Visual verification</p> <ol> <li>Open Google Maps</li> <li>Enter coordinates: <code>43.651234, -79.381234</code></li> <li>Verify location matches address</li> <li>If wrong, get correct coordinates from Google Maps</li> </ol>"},{"location":"v2/troubleshooting/geocoding-issues/#prevention_8","title":"Prevention","text":"<ul> <li>Coordinate validation - Check ranges before save</li> <li>Visual preview - Show on map before save</li> <li>Import validation - Validate imported coordinates</li> <li>Decimal precision - Round to 6 decimals</li> </ul>"},{"location":"v2/troubleshooting/geocoding-issues/#out-of-bounds-coordinates","title":"Out of Bounds Coordinates","text":"<p>Severity: \ud83d\udfe2 Low</p>"},{"location":"v2/troubleshooting/geocoding-issues/#symptoms_9","title":"Symptoms","text":"<p>Markers appear outside expected area (different country/continent).</p>"},{"location":"v2/troubleshooting/geocoding-issues/#solutions_9","title":"Solutions","text":"<p>Solution 1: Set map bounds</p> <pre><code>// 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</code></pre> <p>Solution 2: Filter locations by bounds</p> <pre><code>// 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</code></pre>"},{"location":"v2/troubleshooting/geocoding-issues/#projection-errors-nar-data","title":"Projection Errors (NAR Data)","text":"<p>Severity: \ud83d\udfe1 Medium</p>"},{"location":"v2/troubleshooting/geocoding-issues/#symptoms_10","title":"Symptoms","text":"<p>Locations imported from NAR data appear in wrong place.</p>"},{"location":"v2/troubleshooting/geocoding-issues/#common-causes_9","title":"Common Causes","text":"<ol> <li>Wrong projection - NAR uses EPSG:3347 (Lambert), not WGS84</li> <li>Missing conversion - Coordinates not converted to lat/lng</li> <li>Coordinate swap - BG_X and BG_Y reversed</li> </ol>"},{"location":"v2/troubleshooting/geocoding-issues/#solutions_10","title":"Solutions","text":"<p>Solution 1: Verify NAR import uses proj4</p> <p>In <code>api/src/modules/map/locations/nar-import.service.ts</code>:</p> <pre><code>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</code></pre> <p>Solution 2: Check coordinate order</p> <p>NAR Address files: - BG_X: Easting (X coordinate in meters) - BG_Y: Northing (Y coordinate in meters)</p> <p>Conversion order: <code>[BG_X, BG_Y]</code> \u2192 <code>[longitude, latitude]</code></p> <p>Solution 3: Verify conversion</p> <pre><code># 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</code></pre> <p>Solution 4: Re-import NAR data</p> <p>If imported incorrectly:</p> <pre><code># 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</code></pre>"},{"location":"v2/troubleshooting/geocoding-issues/#prevention_9","title":"Prevention","text":"<ul> <li>Projection validation - Test conversion on sample data</li> <li>Visual verification - Show import preview on map</li> <li>Documentation - Document NAR projection requirements</li> <li>Import validation - Check coordinates are in expected range</li> </ul>"},{"location":"v2/troubleshooting/geocoding-issues/#queue-issues","title":"Queue Issues","text":""},{"location":"v2/troubleshooting/geocoding-issues/#geocoding-queue-stuck","title":"Geocoding Queue Stuck","text":"<p>Severity: \ud83d\udfe1 Medium</p>"},{"location":"v2/troubleshooting/geocoding-issues/#symptoms_11","title":"Symptoms","text":"<p>Locations remain ungeocoded even though queue is running.</p> <p>Queue shows jobs but they never process.</p>"},{"location":"v2/troubleshooting/geocoding-issues/#solutions_11","title":"Solutions","text":"<p>Solution 1: Check queue status</p> <pre><code># 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</code></pre> <p>Solution 2: Check worker is running</p> <pre><code># 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</code></pre> <p>Solution 3: Restart queue worker</p> <pre><code># Restart API (restarts worker)\ndocker compose restart api\n\n# Check worker started\ndocker compose logs api | grep \"Geocoding worker started\"\n</code></pre> <p>Solution 4: Check Redis connection</p> <pre><code># 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</code></pre> <p>Solution 5: Manually process stuck jobs</p> <pre><code># 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</code></pre>"},{"location":"v2/troubleshooting/geocoding-issues/#prevention_10","title":"Prevention","text":"<ul> <li>Health checks - Monitor worker health</li> <li>Dead letter queue - Move repeatedly failed jobs</li> <li>Alerting - Alert if queue backed up</li> <li>Auto-restart - Restart worker if stuck</li> </ul>"},{"location":"v2/troubleshooting/geocoding-issues/#jobs-failing","title":"Jobs Failing","text":"<p>Severity: \ud83d\udfe1 Medium</p>"},{"location":"v2/troubleshooting/geocoding-issues/#symptoms_12","title":"Symptoms","text":"<p>Queue shows high failed job count.</p> <p>Locations remain ungeocoded with error status.</p>"},{"location":"v2/troubleshooting/geocoding-issues/#solutions_12","title":"Solutions","text":"<p>Solution 1: View failed jobs</p> <pre><code># 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</code></pre> <p>Solution 2: Check error patterns</p> <pre><code># 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</code></pre> <p>Solution 3: Retry with different settings</p> <pre><code># 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</code></pre> <p>Solution 4: Manual intervention</p> <p>For repeatedly failing addresses:</p> <ol> <li>Open LocationsPage</li> <li>Find failed locations</li> <li>Review address (fix typos)</li> <li>Manually set coordinates if needed</li> <li>Or delete if invalid</li> </ol>"},{"location":"v2/troubleshooting/geocoding-issues/#prevention_11","title":"Prevention","text":"<ul> <li>Retry logic - Auto-retry with exponential backoff</li> <li>Error categorization - Permanent vs transient failures</li> <li>Manual review queue - Flag for manual review after N attempts</li> <li>Address validation - Validate before geocoding</li> </ul>"},{"location":"v2/troubleshooting/geocoding-issues/#performance-issues","title":"Performance Issues","text":""},{"location":"v2/troubleshooting/geocoding-issues/#slow-geocoding","title":"Slow Geocoding","text":"<p>Severity: \ud83d\udfe1 Medium</p>"},{"location":"v2/troubleshooting/geocoding-issues/#symptoms_13","title":"Symptoms","text":"<p>Geocoding takes 5-10+ seconds per address.</p> <p>Bulk imports very slow.</p>"},{"location":"v2/troubleshooting/geocoding-issues/#solutions_13","title":"Solutions","text":"<p>Solution 1: Use faster providers first</p> <p>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</p> <p>Configure in <code>api/src/modules/map/geocoding/geocoding.service.ts</code>.</p> <p>Solution 2: Increase concurrency</p> <p>In geocoding queue worker:</p> <pre><code>// 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</code></pre> <p>Solution 3: Use bulk geocoding APIs</p> <p>Some providers offer batch geocoding:</p> <pre><code># Google Batch Geocoding (requires Business plan)\n# Can geocode up to 100 addresses in one request\n</code></pre> <p>Solution 4: Cache results</p> <pre><code>// 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</code></pre> <p>Solution 5: Parallel processing</p> <pre><code>// 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</code></pre>"},{"location":"v2/troubleshooting/geocoding-issues/#prevention_12","title":"Prevention","text":"<ul> <li>Queue system - Don't block UI on geocoding</li> <li>Paid tiers - Faster with API keys</li> <li>Caching - Cache frequent addresses</li> <li>Parallel processing - Process multiple at once</li> </ul>"},{"location":"v2/troubleshooting/geocoding-issues/#too-many-api-calls","title":"Too Many API Calls","text":"<p>Severity: \ud83d\udfe1 Medium</p>"},{"location":"v2/troubleshooting/geocoding-issues/#symptoms_14","title":"Symptoms","text":"<p>High API usage on Google/Mapbox.</p> <p>Approaching or exceeding quota.</p>"},{"location":"v2/troubleshooting/geocoding-issues/#solutions_14","title":"Solutions","text":"<p>Solution 1: Monitor usage</p> <pre><code># 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</code></pre> <p>Solution 2: Use free providers first</p> <p>Reorder provider priority:</p> <pre><code>// 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</code></pre> <p>Solution 3: Cache aggressively</p> <pre><code>// 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</code></pre> <p>Solution 4: Deduplicate requests</p> <pre><code># 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</code></pre> <p>Solution 5: Set quota alerts</p> <p>In Google Cloud Console: 1. Navigate to Geocoding API 2. Set quota alerts (e.g., 80% of limit) 3. Receive email before exceeding quota</p>"},{"location":"v2/troubleshooting/geocoding-issues/#prevention_13","title":"Prevention","text":"<ul> <li>Cache everything - Never geocode same address twice</li> <li>Free providers first - Use paid only as fallback</li> <li>Quota monitoring - Alert before exceeding</li> <li>Cost tracking - Monitor API costs monthly</li> </ul>"},{"location":"v2/troubleshooting/geocoding-issues/#data-quality","title":"Data Quality","text":""},{"location":"v2/troubleshooting/geocoding-issues/#duplicate-locations","title":"Duplicate Locations","text":"<p>Severity: \ud83d\udfe2 Low</p>"},{"location":"v2/troubleshooting/geocoding-issues/#symptoms_15","title":"Symptoms","text":"<p>Same address appears multiple times in locations table.</p>"},{"location":"v2/troubleshooting/geocoding-issues/#solutions_15","title":"Solutions","text":"<p>Solution 1: Find duplicates</p> <pre><code># 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</code></pre> <p>Solution 2: Merge duplicates</p> <pre><code># 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</code></pre> <p>Solution 3: Add unique constraint</p> <pre><code>model Location {\n id String @id @default(uuid())\n address String\n\n @@unique([address]) // Prevent duplicates\n}\n</code></pre>"},{"location":"v2/troubleshooting/geocoding-issues/#prevention_14","title":"Prevention","text":"<ul> <li>Unique constraints - Database prevents duplicates</li> <li>Upsert logic - Update if exists, create if not</li> <li>Import validation - Check for duplicates before import</li> <li>Case-insensitive comparison - Normalize before checking</li> </ul>"},{"location":"v2/troubleshooting/geocoding-issues/#ungeocoded-locations","title":"Ungeocoded Locations","text":"<p>Severity: \ud83d\udfe1 Medium</p>"},{"location":"v2/troubleshooting/geocoding-issues/#symptoms_16","title":"Symptoms","text":"<p>Many locations with <code>null</code> latitude/longitude.</p>"},{"location":"v2/troubleshooting/geocoding-issues/#solutions_16","title":"Solutions","text":"<p>Solution 1: Count ungeocoded</p> <pre><code>docker compose exec v2-postgres psql -U changemaker -d changemaker_v2 \\\n -c \"SELECT COUNT(*) FROM \\\"Location\\\" WHERE latitude IS NULL;\"\n</code></pre> <p>Solution 2: Queue all ungeocoded</p> <pre><code># 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</code></pre> <p>Solution 3: View on Data Quality Dashboard</p> <p>Navigate to <code>/app/map/data-quality</code>:</p> <ul> <li>Shows geocoding rate</li> <li>Lists ungeocoded locations</li> <li>Allows bulk geocoding</li> </ul> <p>Solution 4: Export ungeocoded for manual review</p> <pre><code># 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</code></pre>"},{"location":"v2/troubleshooting/geocoding-issues/#prevention_15","title":"Prevention","text":"<ul> <li>Geocode on create - Auto-geocode new locations</li> <li>Required coordinates - Don't allow creating without geocoding</li> <li>Dashboard monitoring - Track geocoding rate</li> <li>Regular cleanup - Periodic geocoding of ungeocoded</li> </ul>"},{"location":"v2/troubleshooting/geocoding-issues/#useful-commands","title":"Useful Commands","text":""},{"location":"v2/troubleshooting/geocoding-issues/#geocoding-operations","title":"Geocoding Operations","text":"<pre><code># 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</code></pre>"},{"location":"v2/troubleshooting/geocoding-issues/#database-queries","title":"Database Queries","text":"<pre><code># 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</code></pre>"},{"location":"v2/troubleshooting/geocoding-issues/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/troubleshooting/geocoding-issues/#geocoding-documentation","title":"Geocoding Documentation","text":"<ul> <li>Geocoding Issues - This guide</li> <li>Locations Feature - Location management</li> <li>Data Quality Dashboard - Monitoring geocoding</li> </ul>"},{"location":"v2/troubleshooting/geocoding-issues/#other-troubleshooting","title":"Other Troubleshooting","text":"<ul> <li>Common Errors - General errors</li> <li>Performance Optimization - Speed improvements</li> </ul>"},{"location":"v2/troubleshooting/geocoding-issues/#external-resources","title":"External Resources","text":"<ul> <li>Nominatim Usage Policy</li> <li>Google Geocoding API</li> <li>Mapbox Geocoding API</li> <li>Leaflet Documentation</li> </ul> <p>Last Updated: February 2026 Version: V2.0 Status: Complete</p>"},{"location":"v2/troubleshooting/monitoring-issues/","title":"Monitoring and Observability Issues","text":"<p>This guide covers Prometheus, Grafana, and observability stack problems in Changemaker Lite V2.</p>"},{"location":"v2/troubleshooting/monitoring-issues/#overview","title":"Overview","text":""},{"location":"v2/troubleshooting/monitoring-issues/#monitoring-stack","title":"Monitoring Stack","text":"<p>Changemaker Lite V2 uses profile-based monitoring (optional):</p> <pre><code># Start with monitoring\ndocker compose --profile monitoring up -d\n</code></pre> <p>Components:</p> <ul> <li>Prometheus - Metrics collection and storage (port 9090)</li> <li>Grafana - Metrics visualization (port 3001)</li> <li>Alertmanager - Alert routing and notification (port 9093)</li> <li>cAdvisor - Container metrics (port 8080)</li> <li>Node Exporter - Host metrics (port 9100)</li> <li>Redis Exporter - Redis metrics (port 9121)</li> </ul>"},{"location":"v2/troubleshooting/monitoring-issues/#custom-metrics","title":"Custom Metrics","text":"<p>12 custom <code>cm_*</code> Prometheus metrics:</p> <ol> <li><code>cm_api_uptime_seconds</code> - API uptime</li> <li><code>cm_database_uptime_seconds</code> - Database uptime</li> <li><code>cm_email_queue_size</code> - Email queue depth</li> <li><code>cm_geocoding_queue_size</code> - Geocoding queue depth</li> <li><code>cm_users_total</code> - Total users</li> <li><code>cm_campaigns_total</code> - Total campaigns</li> <li><code>cm_locations_total</code> - Total locations</li> <li><code>cm_geocoded_locations_total</code> - Geocoded locations</li> <li><code>cm_active_canvass_sessions</code> - Active sessions</li> <li><code>cm_external_service_up</code> - Service health (0/1)</li> <li><code>cm_listmonk_subscribers_total</code> - Listmonk subscribers</li> <li><code>cm_media_videos_total</code> - Total videos</li> </ol> <p>Plus standard HTTP metrics: - <code>http_request_duration_seconds</code> - <code>http_requests_total</code></p>"},{"location":"v2/troubleshooting/monitoring-issues/#prometheus-not-scraping","title":"Prometheus Not Scraping","text":""},{"location":"v2/troubleshooting/monitoring-issues/#target-down","title":"Target Down","text":"<p>Severity: \ud83d\udd34 Critical</p>"},{"location":"v2/troubleshooting/monitoring-issues/#symptoms","title":"Symptoms","text":"<p>Prometheus UI (localhost:9090) shows targets as \"DOWN\":</p> <pre><code>Target: api (localhost:4000/metrics)\nState: DOWN\nError: Get \"http://api:4000/metrics\": connection refused\n</code></pre> <p>No data in Grafana dashboards.</p>"},{"location":"v2/troubleshooting/monitoring-issues/#common-causes","title":"Common Causes","text":"<ol> <li>Service not running - API container stopped</li> <li>Metrics endpoint missing - /metrics endpoint not registered</li> <li>Network issue - Prometheus can't reach service</li> <li>Authentication required - Metrics endpoint requires auth</li> </ol>"},{"location":"v2/troubleshooting/monitoring-issues/#solutions","title":"Solutions","text":"<p>Solution 1: Check service is running</p> <pre><code># Is API running?\ndocker compose ps api\n\n# Should show \"Up\"\n# If not:\ndocker compose up -d api\n</code></pre> <p>Solution 2: Test metrics endpoint</p> <pre><code># 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</code></pre> <p>Solution 3: Check Prometheus config</p> <p>In <code>configs/prometheus/prometheus.yml</code>:</p> <pre><code>scrape_configs:\n - job_name: 'api'\n static_configs:\n - targets: ['api:4000'] # Use service name, not localhost\n</code></pre> <p>Solution 4: Verify network</p> <pre><code># 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</code></pre> <p>Solution 5: Check metrics are registered</p> <p>In API logs:</p> <pre><code>docker compose logs api | grep -i \"metrics\\|prometheus\"\n\n# Should show:\n# Metrics endpoint registered at /metrics\n# Prometheus metrics initialized\n</code></pre>"},{"location":"v2/troubleshooting/monitoring-issues/#prevention","title":"Prevention","text":"<ul> <li>Health checks - Monitor Prometheus target health</li> <li>Service dependencies - Ensure services start in order</li> <li>Network config - Use Docker service names</li> <li>Testing - Test /metrics endpoint on deploy</li> </ul>"},{"location":"v2/troubleshooting/monitoring-issues/#scrape-timeout","title":"Scrape Timeout","text":"<p>Severity: \ud83d\udfe1 Medium</p>"},{"location":"v2/troubleshooting/monitoring-issues/#symptoms_1","title":"Symptoms","text":"<pre><code>Target: api\nState: UP\nLast Scrape: 5.2s (slow)\nLast Error: context deadline exceeded\n</code></pre> <p>Scrapes taking too long or timing out.</p>"},{"location":"v2/troubleshooting/monitoring-issues/#solutions_1","title":"Solutions","text":"<p>Solution 1: Increase scrape timeout</p> <p>In <code>configs/prometheus/prometheus.yml</code>:</p> <pre><code>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</code></pre> <p>Reload config:</p> <pre><code># Reload Prometheus config\ndocker compose exec prometheus kill -HUP 1\n\n# Or restart\ndocker compose restart prometheus\n</code></pre> <p>Solution 2: Optimize metrics generation</p> <pre><code>// 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</code></pre> <p>Solution 3: Reduce metric cardinality</p> <pre><code>// 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</code></pre>"},{"location":"v2/troubleshooting/monitoring-issues/#prevention_1","title":"Prevention","text":"<ul> <li>Cache expensive metrics - Don't query DB on every scrape</li> <li>Reasonable timeouts - 10-30s timeouts</li> <li>Low cardinality - Avoid high-cardinality labels</li> <li>Optimize queries - Fast metric queries</li> </ul>"},{"location":"v2/troubleshooting/monitoring-issues/#authentication-errors","title":"Authentication Errors","text":"<p>Severity: \ud83d\udfe1 Medium</p>"},{"location":"v2/troubleshooting/monitoring-issues/#symptoms_2","title":"Symptoms","text":"<pre><code>Error: 401 Unauthorized when scraping /metrics\n</code></pre>"},{"location":"v2/troubleshooting/monitoring-issues/#solutions_2","title":"Solutions","text":"<p>Changemaker Lite V2 metrics endpoint is public (no auth required).</p> <p>If you see auth errors:</p> <p>Solution 1: Remove auth middleware from /metrics</p> <p>In <code>api/src/server.ts</code>:</p> <pre><code>// 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</code></pre> <p>Solution 2: Configure basic auth in Prometheus</p> <p>If you DO want to protect /metrics:</p> <p>In <code>configs/prometheus/prometheus.yml</code>:</p> <pre><code>scrape_configs:\n - job_name: 'api'\n static_configs:\n - targets: ['api:4000']\n basic_auth:\n username: 'prometheus'\n password: 'your-password'\n</code></pre>"},{"location":"v2/troubleshooting/monitoring-issues/#prevention_2","title":"Prevention","text":"<ul> <li>Public metrics - Keep /metrics public for simplicity</li> <li>Network isolation - Use Docker networks for security</li> <li>IP whitelist - Only allow Prometheus IP</li> </ul>"},{"location":"v2/troubleshooting/monitoring-issues/#grafana-issues","title":"Grafana Issues","text":""},{"location":"v2/troubleshooting/monitoring-issues/#dashboards-not-loading","title":"Dashboards Not Loading","text":"<p>Severity: \ud83d\udfe0 High</p>"},{"location":"v2/troubleshooting/monitoring-issues/#symptoms_3","title":"Symptoms","text":"<p>Grafana shows blank dashboards or \"No data\" panels.</p>"},{"location":"v2/troubleshooting/monitoring-issues/#solutions_3","title":"Solutions","text":"<p>Solution 1: Check Grafana is running</p> <pre><code>docker compose --profile monitoring ps grafana\n\n# Should show \"Up\"\n# If not:\ndocker compose --profile monitoring up -d grafana\n</code></pre> <p>Solution 2: Verify Prometheus datasource</p> <ol> <li>Open Grafana: http://localhost:3001</li> <li>Login (admin/admin)</li> <li>Settings \u2192 Data Sources</li> <li>Click Prometheus</li> <li>URL should be: <code>http://prometheus:9090</code></li> <li>Click \"Save & Test\"</li> <li>Should show \"Data source is working\"</li> </ol> <p>Solution 3: Check dashboard provisioning</p> <pre><code># 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</code></pre> <p>Solution 4: Import dashboard manually</p> <p>If auto-provisioning fails:</p> <ol> <li>Grafana \u2192 Dashboards \u2192 Import</li> <li>Upload JSON from <code>configs/grafana/dashboards/</code></li> <li>Select Prometheus datasource</li> <li>Click Import</li> </ol> <p>Solution 5: Check for data</p> <pre><code># 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</code></pre>"},{"location":"v2/troubleshooting/monitoring-issues/#prevention_3","title":"Prevention","text":"<ul> <li>Dashboard versioning - Keep dashboards in git</li> <li>Auto-provisioning - Use provisioning instead of manual import</li> <li>Testing - Test dashboards after changes</li> <li>Documentation - Document dashboard variables</li> </ul>"},{"location":"v2/troubleshooting/monitoring-issues/#datasource-errors","title":"Datasource Errors","text":"<p>Severity: \ud83d\udfe0 High</p>"},{"location":"v2/troubleshooting/monitoring-issues/#symptoms_4","title":"Symptoms","text":"<pre><code>Error: Failed to query Prometheus\nError: connection refused\n</code></pre> <p>Red error bars on Grafana panels.</p>"},{"location":"v2/troubleshooting/monitoring-issues/#solutions_4","title":"Solutions","text":"<p>Solution 1: Test Prometheus connection</p> <pre><code># 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</code></pre> <p>Solution 2: Check Prometheus is running</p> <pre><code>docker compose --profile monitoring ps prometheus\n\n# Should show \"Up\"\n</code></pre> <p>Solution 3: Verify datasource URL</p> <p>In Grafana datasource settings: - URL: <code>http://prometheus:9090</code> (NOT <code>http://localhost:9090</code>) - Access: Server (NOT Browser)</p> <p>Solution 4: Check Docker network</p> <pre><code># Same network?\ndocker inspect changemaker-lite-grafana-1 | grep NetworkMode\ndocker inspect changemaker-lite-prometheus-1 | grep NetworkMode\n</code></pre>"},{"location":"v2/troubleshooting/monitoring-issues/#prevention_4","title":"Prevention","text":"<ul> <li>Health checks - Monitor datasource health</li> <li>Service dependencies - Start Prometheus before Grafana</li> <li>Error handling - Graceful error messages</li> </ul>"},{"location":"v2/troubleshooting/monitoring-issues/#query-errors","title":"Query Errors","text":"<p>Severity: \ud83d\udfe1 Medium</p>"},{"location":"v2/troubleshooting/monitoring-issues/#symptoms_5","title":"Symptoms","text":"<pre><code>Error executing query: parse error at char X: unexpected identifier\n</code></pre> <p>Panel shows \"Error loading data\".</p>"},{"location":"v2/troubleshooting/monitoring-issues/#solutions_5","title":"Solutions","text":"<p>Solution 1: Validate PromQL syntax</p> <p>Common errors:</p> <pre><code># 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</code></pre> <p>Solution 2: Test query in Explore</p> <ol> <li>Grafana \u2192 Explore</li> <li>Enter query</li> <li>Run</li> <li>Fix errors before adding to dashboard</li> </ol> <p>Solution 3: Check metric exists</p> <pre><code># 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</code></pre> <p>Solution 4: Use metric browser</p> <p>In Grafana query editor: 1. Click \"Metrics\" button 2. Browse available metrics 3. Select metric (auto-fills query)</p>"},{"location":"v2/troubleshooting/monitoring-issues/#prevention_5","title":"Prevention","text":"<ul> <li>Query validation - Validate before saving</li> <li>Testing - Test queries in Explore</li> <li>Documentation - Document available metrics</li> <li>Examples - Provide query examples</li> </ul>"},{"location":"v2/troubleshooting/monitoring-issues/#alertmanager-issues","title":"Alertmanager Issues","text":""},{"location":"v2/troubleshooting/monitoring-issues/#alerts-not-firing","title":"Alerts Not Firing","text":"<p>Severity: \ud83d\udfe0 High</p>"},{"location":"v2/troubleshooting/monitoring-issues/#symptoms_6","title":"Symptoms","text":"<p>Conditions met but alert not triggering.</p>"},{"location":"v2/troubleshooting/monitoring-issues/#solutions_6","title":"Solutions","text":"<p>Solution 1: Check alert rules</p> <p>In Prometheus UI (localhost:9090):</p> <ol> <li>Click \"Alerts\"</li> <li>Find your alert</li> <li>Check state:</li> <li>Inactive: Condition not met</li> <li>Pending: Met but waiting for <code>for:</code> duration</li> <li>Firing: Alert active</li> </ol> <p>Solution 2: Verify alert rule syntax</p> <p>In <code>configs/prometheus/alerts.yml</code>:</p> <pre><code>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</code></pre> <p>Solution 3: Check Alertmanager config</p> <pre><code># Test Alertmanager\ncurl http://localhost:9093/api/v1/alerts\n\n# Should return alert list\n</code></pre> <p>Solution 4: View Prometheus logs</p> <pre><code>docker compose logs prometheus | grep -i alert\n\n# Shows:\n# Loaded alert rules\n# Alert X is firing\n</code></pre> <p>Solution 5: Reload alert rules</p> <pre><code># Reload Prometheus config\ndocker compose exec prometheus kill -HUP 1\n\n# Check rules loaded\ncurl http://localhost:9090/api/v1/rules\n</code></pre>"},{"location":"v2/troubleshooting/monitoring-issues/#prevention_6","title":"Prevention","text":"<ul> <li>Test alert conditions - Trigger manually to test</li> <li>Reasonable thresholds - Not too sensitive or too lenient</li> <li>Documentation - Document alert thresholds</li> <li>Regular review - Review alert effectiveness</li> </ul>"},{"location":"v2/troubleshooting/monitoring-issues/#notifications-not-sent","title":"Notifications Not Sent","text":"<p>Severity: \ud83d\udfe1 Medium</p>"},{"location":"v2/troubleshooting/monitoring-issues/#symptoms_7","title":"Symptoms","text":"<p>Alert firing in Prometheus but no notification received.</p>"},{"location":"v2/troubleshooting/monitoring-issues/#solutions_7","title":"Solutions","text":"<p>Solution 1: Check Alertmanager config</p> <p>In <code>configs/alertmanager/alertmanager.yml</code>:</p> <pre><code>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</code></pre> <p>Solution 2: Test Alertmanager notification</p> <pre><code># 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</code></pre> <p>Solution 3: Check SMTP config</p> <p>See Email Issues for SMTP troubleshooting.</p> <p>Solution 4: Use alternative notification channels</p> <pre><code>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</code></pre>"},{"location":"v2/troubleshooting/monitoring-issues/#prevention_7","title":"Prevention","text":"<ul> <li>Test notifications - Regular notification tests</li> <li>Multiple channels - Email + Slack + webhook</li> <li>Fallback receivers - Backup notification method</li> <li>Documentation - Document notification setup</li> </ul>"},{"location":"v2/troubleshooting/monitoring-issues/#routing-errors","title":"Routing Errors","text":"<p>Severity: \ud83d\udfe1 Medium</p>"},{"location":"v2/troubleshooting/monitoring-issues/#symptoms_8","title":"Symptoms","text":"<p>Alerts going to wrong receiver or being silenced incorrectly.</p>"},{"location":"v2/troubleshooting/monitoring-issues/#solutions_8","title":"Solutions","text":"<p>Solution 1: Check routing rules</p> <p>In <code>configs/alertmanager/alertmanager.yml</code>:</p> <pre><code>route:\n receiver: 'default'\n routes:\n - match:\n severity: critical\n receiver: 'pager'\n - match:\n severity: warning\n receiver: 'email'\n</code></pre> <p>Solution 2: Test routing</p> <pre><code># 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</code></pre> <p>Solution 3: View active silences</p> <p>In Alertmanager UI (localhost:9093):</p> <ol> <li>Click \"Silences\"</li> <li>Check if alert is silenced</li> <li>Expire or delete silence if wrong</li> </ol> <p>Solution 4: Check inhibition rules</p> <pre><code>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</code></pre>"},{"location":"v2/troubleshooting/monitoring-issues/#prevention_8","title":"Prevention","text":"<ul> <li>Clear routing logic - Simple, understandable rules</li> <li>Test routing - Test before deploying</li> <li>Documentation - Document routing rules</li> <li>Regular review - Review silences and inhibitions</li> </ul>"},{"location":"v2/troubleshooting/monitoring-issues/#metrics-issues","title":"Metrics Issues","text":""},{"location":"v2/troubleshooting/monitoring-issues/#missing-metrics","title":"Missing Metrics","text":"<p>Severity: \ud83d\udfe1 Medium</p>"},{"location":"v2/troubleshooting/monitoring-issues/#symptoms_9","title":"Symptoms","text":"<p>Expected metric not appearing in Prometheus or Grafana.</p>"},{"location":"v2/troubleshooting/monitoring-issues/#solutions_9","title":"Solutions","text":"<p>Solution 1: Check metric is registered</p> <p>In API code (<code>api/src/utils/metrics.ts</code>):</p> <pre><code>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</code></pre> <p>Solution 2: Check metric is collected</p> <pre><code># 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</code></pre> <p>Solution 3: Check scrape config</p> <p>In <code>configs/prometheus/prometheus.yml</code>:</p> <pre><code>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</code></pre> <p>Solution 4: Verify metric type</p> <pre><code>// 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</code></pre>"},{"location":"v2/troubleshooting/monitoring-issues/#prevention_9","title":"Prevention","text":"<ul> <li>Register all metrics - Don't forget register.registerMetric()</li> <li>Test endpoint - Check /metrics shows metric</li> <li>Naming convention - Use cm_* prefix for custom metrics</li> <li>Documentation - Document all custom metrics</li> </ul>"},{"location":"v2/troubleshooting/monitoring-issues/#incorrect-values","title":"Incorrect Values","text":"<p>Severity: \ud83d\udfe1 Medium</p>"},{"location":"v2/troubleshooting/monitoring-issues/#symptoms_10","title":"Symptoms","text":"<p>Metric showing wrong or unexpected values.</p>"},{"location":"v2/troubleshooting/monitoring-issues/#solutions_10","title":"Solutions","text":"<p>Solution 1: Check metric logic</p> <pre><code>// 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</code></pre> <p>Solution 2: Check metric type</p> <pre><code>// 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</code></pre> <p>Solution 3: Check label values</p> <pre><code>// 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</code></pre> <p>Solution 4: Check query aggregation</p> <pre><code># Wrong - sums across all labels\nsum(cm_requests_total)\n\n# Right - sum by specific label\nsum by (status) (cm_requests_total)\n</code></pre>"},{"location":"v2/troubleshooting/monitoring-issues/#prevention_10","title":"Prevention","text":"<ul> <li>Correct metric type - Counter vs Gauge vs Histogram</li> <li>Type consistency - Label values always same type</li> <li>Testing - Test metric values with sample data</li> <li>Validation - Validate metric values are reasonable</li> </ul>"},{"location":"v2/troubleshooting/monitoring-issues/#stale-metrics","title":"Stale Metrics","text":"<p>Severity: \ud83d\udfe2 Low</p>"},{"location":"v2/troubleshooting/monitoring-issues/#symptoms_11","title":"Symptoms","text":"<p>Metric values not updating, showing old data.</p>"},{"location":"v2/troubleshooting/monitoring-issues/#solutions_11","title":"Solutions","text":"<p>Solution 1: Check collection frequency</p> <pre><code>// 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</code></pre> <p>Solution 2: Force metric update</p> <pre><code>// Update metric on event, not just scrape\neventEmitter.on('queueSizeChanged', (size) => {\n queueSizeGauge.set(size);\n});\n</code></pre> <p>Solution 3: Check scrape interval</p> <p>In <code>configs/prometheus/prometheus.yml</code>:</p> <pre><code>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</code></pre>"},{"location":"v2/troubleshooting/monitoring-issues/#prevention_11","title":"Prevention","text":"<ul> <li>Appropriate intervals - Balance freshness vs overhead</li> <li>Event-driven updates - Update on change, not just scrape</li> <li>Cache expensive metrics - Don't query DB every scrape</li> <li>Staleness markers - Set metrics to NaN when stale</li> </ul>"},{"location":"v2/troubleshooting/monitoring-issues/#performance-issues","title":"Performance Issues","text":""},{"location":"v2/troubleshooting/monitoring-issues/#high-memory-usage","title":"High Memory Usage","text":"<p>Severity: \ud83d\udfe0 High</p>"},{"location":"v2/troubleshooting/monitoring-issues/#symptoms_12","title":"Symptoms","text":"<p>Prometheus container using excessive memory (multiple GB).</p>"},{"location":"v2/troubleshooting/monitoring-issues/#solutions_12","title":"Solutions","text":"<p>Solution 1: Reduce retention period</p> <p>In <code>docker-compose.yml</code>:</p> <pre><code>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</code></pre> <p>Restart:</p> <pre><code>docker compose --profile monitoring restart prometheus\n</code></pre> <p>Solution 2: Reduce metric cardinality</p> <pre><code>// 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</code></pre> <p>Solution 3: Drop unnecessary metrics</p> <p>In <code>configs/prometheus/prometheus.yml</code>:</p> <pre><code>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</code></pre> <p>Solution 4: Increase memory limit</p> <pre><code>prometheus:\n deploy:\n resources:\n limits:\n memory: 4G # Increase from 2G\n</code></pre>"},{"location":"v2/troubleshooting/monitoring-issues/#prevention_12","title":"Prevention","text":"<ul> <li>Low cardinality - Avoid high-cardinality labels</li> <li>Appropriate retention - 7-30 days is usually enough</li> <li>Regular cleanup - Drop unused metrics</li> <li>Monitor memory - Alert on high usage</li> </ul>"},{"location":"v2/troubleshooting/monitoring-issues/#slow-queries","title":"Slow Queries","text":"<p>Severity: \ud83d\udfe1 Medium</p>"},{"location":"v2/troubleshooting/monitoring-issues/#symptoms_13","title":"Symptoms","text":"<p>Grafana dashboards slow to load. Queries taking 10+ seconds.</p>"},{"location":"v2/troubleshooting/monitoring-issues/#solutions_13","title":"Solutions","text":"<p>Solution 1: Optimize query</p> <pre><code># 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</code></pre> <p>Solution 2: Use recording rules</p> <p>In <code>configs/prometheus/alerts.yml</code>:</p> <pre><code>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</code></pre> <p>Solution 3: Reduce time range</p> <p>In Grafana: - Change dashboard time range from \"Last 30 days\" to \"Last 24 hours\" - Queries are faster with less data</p> <p>Solution 4: Increase Prometheus resources</p> <pre><code>prometheus:\n deploy:\n resources:\n limits:\n cpus: '2.0' # More CPU for queries\n memory: 4G\n</code></pre>"},{"location":"v2/troubleshooting/monitoring-issues/#prevention_13","title":"Prevention","text":"<ul> <li>Efficient queries - Keep queries simple</li> <li>Recording rules - Pre-calculate expensive queries</li> <li>Appropriate time ranges - Don't query months of data</li> <li>Indexing - Prometheus auto-indexes, but cardinality affects performance</li> </ul>"},{"location":"v2/troubleshooting/monitoring-issues/#useful-commands","title":"Useful Commands","text":""},{"location":"v2/troubleshooting/monitoring-issues/#prometheus-operations","title":"Prometheus Operations","text":"<pre><code># 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</code></pre>"},{"location":"v2/troubleshooting/monitoring-issues/#grafana-operations","title":"Grafana Operations","text":"<pre><code># 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</code></pre>"},{"location":"v2/troubleshooting/monitoring-issues/#alertmanager-operations","title":"Alertmanager Operations","text":"<pre><code># 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</code></pre>"},{"location":"v2/troubleshooting/monitoring-issues/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/troubleshooting/monitoring-issues/#monitoring-documentation","title":"Monitoring Documentation","text":"<ul> <li>Monitoring Issues - This guide</li> <li>Observability Dashboard - Using dashboard</li> <li>Monitoring Guide - Setup and configuration</li> </ul>"},{"location":"v2/troubleshooting/monitoring-issues/#other-troubleshooting","title":"Other Troubleshooting","text":"<ul> <li>Common Errors - General errors</li> <li>Performance Optimization - Performance tuning</li> </ul>"},{"location":"v2/troubleshooting/monitoring-issues/#external-resources","title":"External Resources","text":"<ul> <li>Prometheus Documentation</li> <li>Grafana Documentation</li> <li>Alertmanager Documentation</li> <li>PromQL Tutorial</li> </ul> <p>Last Updated: February 2026 Version: V2.0 Status: Complete</p>"},{"location":"v2/troubleshooting/performance-optimization/","title":"Performance Optimization","text":"<p>This guide covers performance tuning and optimization strategies for Changemaker Lite V2.</p>"},{"location":"v2/troubleshooting/performance-optimization/#overview","title":"Overview","text":""},{"location":"v2/troubleshooting/performance-optimization/#performance-areas","title":"Performance Areas","text":"<ol> <li>Database - Query optimization, indexing, connection pooling</li> <li>API - Caching, rate limiting, pagination</li> <li>Frontend - Code splitting, lazy loading, bundling</li> <li>Docker - Resource limits, multi-stage builds</li> <li>Nginx - Compression, caching, keep-alive</li> <li>Email Queue - Worker count, batch processing</li> <li>Monitoring - Prometheus metrics, Grafana dashboards</li> </ol>"},{"location":"v2/troubleshooting/performance-optimization/#performance-metrics","title":"Performance Metrics","text":"<p>Target performance:</p> <ul> <li>API response time: < 200ms (p95)</li> <li>Database query time: < 50ms (p95)</li> <li>Frontend load time: < 2s (initial)</li> <li>Email sending: 100+ emails/minute</li> <li>Concurrent users: 500+</li> </ul>"},{"location":"v2/troubleshooting/performance-optimization/#database-optimization","title":"Database Optimization","text":""},{"location":"v2/troubleshooting/performance-optimization/#index-optimization","title":"Index Optimization","text":"<p>Find missing indexes:</p> <pre><code>-- 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</code></pre> <p>Add indexes to frequently queried columns:</p> <pre><code>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</code></pre> <p>Create migration:</p> <pre><code>docker compose exec api npx prisma migrate dev --name add_location_indexes\n</code></pre> <p>Verify index usage:</p> <pre><code>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</code></pre>"},{"location":"v2/troubleshooting/performance-optimization/#query-optimization","title":"Query Optimization","text":"<p>Use select instead of fetching all fields:</p> <pre><code>// 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</code></pre> <p>Use include instead of separate queries:</p> <pre><code>// 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</code></pre> <p>Paginate large result sets:</p> <pre><code>// 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</code></pre> <p>Use aggregations efficiently:</p> <pre><code>// 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</code></pre>"},{"location":"v2/troubleshooting/performance-optimization/#connection-pooling","title":"Connection Pooling","text":"<p>Configure pool size:</p> <pre><code># 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</code></pre> <p>Recommended pool sizes:</p> <ul> <li>Development: 5-10 connections</li> <li>Production (1 API instance): 10-20 connections</li> <li>Production (3 API instances): 5-10 per instance</li> </ul> <p>Formula:</p> <pre><code>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</code></pre> <p>Monitor pool usage:</p> <pre><code>-- 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</code></pre>"},{"location":"v2/troubleshooting/performance-optimization/#read-replicas","title":"Read Replicas","text":"<p>For read-heavy workloads, add read replicas:</p> <pre><code># 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</code></pre> <p>Configure replication in Prisma:</p> <pre><code>// 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</code></pre>"},{"location":"v2/troubleshooting/performance-optimization/#api-optimization","title":"API Optimization","text":""},{"location":"v2/troubleshooting/performance-optimization/#caching-strategies","title":"Caching Strategies","text":"<p>Redis caching:</p> <pre><code>// 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</code></pre> <p>Invalidate cache on updates:</p> <pre><code>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</code></pre> <p>Cache patterns:</p> <ul> <li>Cache-aside: Check cache, fetch from DB if miss</li> <li>Write-through: Update DB and cache simultaneously</li> <li>Write-behind: Update cache, async update DB</li> <li>TTL: Set expiration time (5min-1hour typical)</li> </ul>"},{"location":"v2/troubleshooting/performance-optimization/#rate-limiting","title":"Rate Limiting","text":"<p>Configure rate limits:</p> <pre><code>// 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</code></pre> <p>Apply to routes:</p> <pre><code>// In server.ts\napp.use('/api/auth', authRateLimit);\napp.use('/api', apiRateLimit);\napp.use('/public', publicRateLimit);\n</code></pre>"},{"location":"v2/troubleshooting/performance-optimization/#pagination","title":"Pagination","text":"<p>Implement cursor-based pagination:</p> <pre><code>// 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</code></pre> <p>Frontend pagination:</p> <pre><code>// 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</code></pre>"},{"location":"v2/troubleshooting/performance-optimization/#response-compression","title":"Response Compression","text":"<p>Enable gzip compression:</p> <pre><code>// 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</code></pre>"},{"location":"v2/troubleshooting/performance-optimization/#frontend-optimization","title":"Frontend Optimization","text":""},{"location":"v2/troubleshooting/performance-optimization/#code-splitting","title":"Code Splitting","text":"<p>Route-based splitting:</p> <pre><code>// 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</code></pre> <p>Component splitting:</p> <pre><code>// 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</code></pre>"},{"location":"v2/troubleshooting/performance-optimization/#lazy-loading","title":"Lazy Loading","text":"<p>Images:</p> <pre><code><img\n src={imageUrl}\n loading=\"lazy\" // Native lazy loading\n alt=\"Description\"\n/>\n</code></pre> <p>Large libraries:</p> <pre><code>// 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</code></pre>"},{"location":"v2/troubleshooting/performance-optimization/#bundle-optimization","title":"Bundle Optimization","text":"<p>Analyze bundle size:</p> <pre><code>cd admin\nnpm run build\nnpx vite-bundle-visualizer\n</code></pre> <p>Tree shaking:</p> <pre><code>// 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</code></pre> <p>Configure Vite:</p> <pre><code>// 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</code></pre>"},{"location":"v2/troubleshooting/performance-optimization/#memoization","title":"Memoization","text":"<p>React.memo for expensive components:</p> <pre><code>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</code></pre> <p>useMemo for expensive calculations:</p> <pre><code>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</code></pre> <p>useCallback for stable functions:</p> <pre><code>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</code></pre>"},{"location":"v2/troubleshooting/performance-optimization/#docker-optimization","title":"Docker Optimization","text":""},{"location":"v2/troubleshooting/performance-optimization/#resource-limits","title":"Resource Limits","text":"<pre><code># 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</code></pre> <p>Monitor resource usage:</p> <pre><code>docker stats\n\n# Shows:\n# CONTAINER CPU % MEM USAGE / LIMIT MEM %\n# api 15% 1.2GB / 4GB 30%\n</code></pre>"},{"location":"v2/troubleshooting/performance-optimization/#multi-stage-builds","title":"Multi-Stage Builds","text":"<p>Optimize Dockerfile:</p> <pre><code># 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</code></pre> <p>Benefits:</p> <ul> <li>Smaller final image (no build tools)</li> <li>Faster deployment</li> <li>Better security (fewer packages)</li> </ul>"},{"location":"v2/troubleshooting/performance-optimization/#volume-performance","title":"Volume Performance","text":"<p>Use cached volumes for dependencies:</p> <pre><code>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</code></pre> <p>For macOS/Windows:</p> <pre><code>api:\n volumes:\n - ./api:/app:cached # Cached mode for better performance\n</code></pre>"},{"location":"v2/troubleshooting/performance-optimization/#nginx-optimization","title":"Nginx Optimization","text":""},{"location":"v2/troubleshooting/performance-optimization/#gzip-compression","title":"Gzip Compression","text":"<pre><code># 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</code></pre>"},{"location":"v2/troubleshooting/performance-optimization/#caching","title":"Caching","text":"<p>Static assets:</p> <pre><code># 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</code></pre> <p>API responses:</p> <pre><code>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</code></pre>"},{"location":"v2/troubleshooting/performance-optimization/#keep-alive","title":"Keep-Alive","text":"<pre><code># 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</code></pre>"},{"location":"v2/troubleshooting/performance-optimization/#email-queue-optimization","title":"Email Queue Optimization","text":""},{"location":"v2/troubleshooting/performance-optimization/#worker-concurrency","title":"Worker Concurrency","text":"<p>Increase parallel processing:</p> <pre><code>// 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</code></pre> <p>Recommended concurrency:</p> <ul> <li>Development: 1-2</li> <li>Production (low volume): 3-5</li> <li>Production (high volume): 10-20</li> </ul>"},{"location":"v2/troubleshooting/performance-optimization/#batch-processing","title":"Batch Processing","text":"<p>Process emails in batches:</p> <pre><code>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</code></pre>"},{"location":"v2/troubleshooting/performance-optimization/#rate-limiting_1","title":"Rate Limiting","text":"<p>Respect SMTP provider limits:</p> <pre><code>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</code></pre>"},{"location":"v2/troubleshooting/performance-optimization/#monitoring-performance","title":"Monitoring Performance","text":""},{"location":"v2/troubleshooting/performance-optimization/#prometheus-metrics","title":"Prometheus Metrics","text":"<p>Track response times:</p> <pre><code>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</code></pre> <p>Track query counts:</p> <pre><code>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</code></pre>"},{"location":"v2/troubleshooting/performance-optimization/#grafana-dashboards","title":"Grafana Dashboards","text":"<p>Create performance dashboard:</p> <pre><code># 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</code></pre>"},{"location":"v2/troubleshooting/performance-optimization/#slow-query-log","title":"Slow Query Log","text":"<p>Enable in PostgreSQL:</p> <pre><code># docker-compose.yml\nv2-postgres:\n command: postgres -c log_min_duration_statement=100\n # Logs queries taking > 100ms\n</code></pre> <p>View slow queries:</p> <pre><code>docker compose logs v2-postgres | grep \"duration:\"\n\n# Output:\n# LOG: duration: 523.456 ms statement: SELECT * FROM \"Location\" WHERE ...\n</code></pre>"},{"location":"v2/troubleshooting/performance-optimization/#load-testing","title":"Load Testing","text":""},{"location":"v2/troubleshooting/performance-optimization/#k6-load-testing","title":"k6 Load Testing","text":"<p>Install k6:</p> <pre><code># 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</code></pre> <p>Create test script:</p> <pre><code>// 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</code></pre> <p>Run test:</p> <pre><code>k6 run load-test.js\n</code></pre> <p>Interpret results:</p> <pre><code> \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</code></pre>"},{"location":"v2/troubleshooting/performance-optimization/#apache-bench","title":"Apache Bench","text":"<p>Quick load test:</p> <pre><code># 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</code></pre>"},{"location":"v2/troubleshooting/performance-optimization/#performance-checklist","title":"Performance Checklist","text":""},{"location":"v2/troubleshooting/performance-optimization/#database","title":"Database","text":"<ul> <li> Indexes on frequently queried columns</li> <li> Composite indexes for multi-column queries</li> <li> Connection pool sized appropriately</li> <li> Slow query log enabled</li> <li> VACUUM run regularly (auto by default)</li> <li> Read replicas for read-heavy loads</li> </ul>"},{"location":"v2/troubleshooting/performance-optimization/#api","title":"API","text":"<ul> <li> Redis caching for expensive operations</li> <li> Rate limiting on all endpoints</li> <li> Pagination on list endpoints</li> <li> Response compression enabled</li> <li> N+1 queries eliminated</li> <li> Select only needed fields</li> </ul>"},{"location":"v2/troubleshooting/performance-optimization/#frontend","title":"Frontend","text":"<ul> <li> Route-based code splitting</li> <li> Lazy loading for heavy components</li> <li> Images optimized and lazy-loaded</li> <li> Bundle size < 500KB (gzipped)</li> <li> React.memo for expensive components</li> <li> useCallback/useMemo for stable references</li> </ul>"},{"location":"v2/troubleshooting/performance-optimization/#docker","title":"Docker","text":"<ul> <li> Multi-stage builds</li> <li> Resource limits set</li> <li> Health checks configured</li> <li> Volumes optimized</li> <li> Images use Alpine base</li> </ul>"},{"location":"v2/troubleshooting/performance-optimization/#nginx","title":"Nginx","text":"<ul> <li> Gzip compression enabled</li> <li> Static asset caching (1 year)</li> <li> Keep-alive connections</li> <li> Worker processes = CPU cores</li> <li> Access logs rotated</li> </ul>"},{"location":"v2/troubleshooting/performance-optimization/#email-queue","title":"Email Queue","text":"<ul> <li> Worker concurrency optimized</li> <li> Rate limiting respects SMTP limits</li> <li> Batch processing for bulk sends</li> <li> Failed jobs retry with backoff</li> <li> Queue size monitored</li> </ul>"},{"location":"v2/troubleshooting/performance-optimization/#monitoring","title":"Monitoring","text":"<ul> <li> Prometheus metrics collected</li> <li> Grafana dashboards created</li> <li> Alerts configured</li> <li> Slow queries logged</li> <li> Resource usage tracked</li> </ul>"},{"location":"v2/troubleshooting/performance-optimization/#related-documentation","title":"Related Documentation","text":""},{"location":"v2/troubleshooting/performance-optimization/#performance-documentation","title":"Performance Documentation","text":"<ul> <li>Performance Optimization - This guide</li> <li>Monitoring Issues - Observability troubleshooting</li> <li>Database Issues - Database troubleshooting</li> </ul>"},{"location":"v2/troubleshooting/performance-optimization/#other-guides","title":"Other Guides","text":"<ul> <li>Architecture Overview - System design</li> <li>Deployment Guide - Production setup</li> <li>Monitoring Guide - Monitoring setup</li> </ul>"},{"location":"v2/troubleshooting/performance-optimization/#external-resources","title":"External Resources","text":"<ul> <li>PostgreSQL Performance Tips</li> <li>Prisma Performance Guide</li> <li>React Performance</li> <li>Vite Performance</li> </ul> <p>Last Updated: February 2026 Version: V2.0 Status: Complete</p>"},{"location":"v2/user-guides/","title":"User Guides","text":"<p>This section provides step-by-step guides for different user roles and common tasks. Each guide is tailored to specific workflows and responsibilities.</p>"},{"location":"v2/user-guides/#role-based-guides","title":"Role-Based Guides","text":""},{"location":"v2/user-guides/#admin-guide","title":"Admin Guide","text":"<p>For system administrators and site managers:</p> <ul> <li>Initial setup and configuration</li> <li>User management</li> <li>Site settings</li> <li>Service integration</li> <li>Monitoring and maintenance</li> <li>Security best practices</li> </ul> <p>Target Audience: SUPER_ADMIN role</p>"},{"location":"v2/user-guides/#campaign-manager-guide","title":"Campaign Manager Guide","text":"<p>For advocacy campaign coordinators:</p> <ul> <li>Creating campaigns</li> <li>Managing representatives</li> <li>Email template design</li> <li>Response moderation</li> <li>Campaign analytics</li> <li>Email queue monitoring</li> </ul> <p>Target Audience: INFLUENCE_ADMIN role</p>"},{"location":"v2/user-guides/#map-organizer-guide","title":"Map Organizer Guide","text":"<p>For field organizing coordinators:</p> <ul> <li>Location management</li> <li>Importing data (CSV, NAR)</li> <li>Creating geographic cuts</li> <li>Scheduling volunteer shifts</li> <li>Monitoring canvassing progress</li> <li>Printing walk sheets</li> </ul> <p>Target Audience: MAP_ADMIN role</p>"},{"location":"v2/user-guides/#volunteer-guide","title":"Volunteer Guide","text":"<p>For field canvassers:</p> <ul> <li>Viewing shift assignments</li> <li>Starting canvass session</li> <li>Using GPS map</li> <li>Recording visit outcomes</li> <li>Tracking personal activity</li> <li>Best practices for canvassing</li> </ul> <p>Target Audience: USER role</p>"},{"location":"v2/user-guides/#content-editor-guide","title":"Content Editor Guide","text":"<p>For content creators:</p> <ul> <li>Creating landing pages</li> <li>Using GrapesJS editor</li> <li>Email template creation</li> <li>Managing media library</li> <li>Publishing content</li> <li>SEO best practices</li> </ul> <p>Target Audience: SUPER_ADMIN role</p>"},{"location":"v2/user-guides/#common-tasks","title":"Common Tasks","text":""},{"location":"v2/user-guides/#getting-started","title":"Getting Started","text":"<ol> <li>First Login</li> <li>Navigate to http://your-domain.com or http://localhost:3000</li> <li>Login with credentials</li> <li>Change default password</li> <li> <p>Explore dashboard</p> </li> <li> <p>User Role Redirection</p> </li> <li>Admin roles \u2192 <code>/app/dashboard</code></li> <li>User/volunteer roles \u2192 <code>/volunteer/dashboard</code></li> </ol>"},{"location":"v2/user-guides/#campaign-workflow","title":"Campaign Workflow","text":"<ol> <li>Create Campaign</li> <li>Navigate to <code>/app/influence/campaigns</code></li> <li>Click \"New Campaign\"</li> <li>Fill in details</li> <li> <p>Save campaign</p> </li> <li> <p>Design Email Template</p> </li> <li>Set email subject</li> <li>Write email body</li> <li>Use variable placeholders</li> <li> <p>Preview template</p> </li> <li> <p>Launch Campaign</p> </li> <li>Set to published</li> <li>Share public URL</li> <li>Monitor responses</li> </ol>"},{"location":"v2/user-guides/#location-workflow","title":"Location Workflow","text":"<ol> <li>Import Locations</li> <li>Prepare CSV file</li> <li>Navigate to <code>/app/map/locations</code></li> <li>Click \"Import CSV\"</li> <li>Map columns</li> <li> <p>Import data</p> </li> <li> <p>Geocode Addresses</p> </li> <li>Select ungeocode locations</li> <li>Click \"Geocode Selected\"</li> <li>Monitor progress</li> <li> <p>Review quality metrics</p> </li> <li> <p>Create Geographic Cuts</p> </li> <li>Navigate to <code>/app/map/cuts</code></li> <li>Click \"Draw on Map\"</li> <li>Draw polygon</li> <li>Save cut</li> <li>Assign locations</li> </ol>"},{"location":"v2/user-guides/#volunteer-canvassing-workflow","title":"Volunteer Canvassing Workflow","text":"<ol> <li>View Assignments</li> <li>Login as volunteer</li> <li>Navigate to <code>/volunteer/assignments</code></li> <li> <p>View upcoming shifts</p> </li> <li> <p>Start Canvassing</p> </li> <li>Click \"Start Canvass\"</li> <li>Grant GPS permissions</li> <li>Follow walking route</li> <li> <p>Visit locations</p> </li> <li> <p>Record Visits</p> </li> <li>Click location marker</li> <li>Select outcome</li> <li>Add notes</li> <li> <p>Submit</p> </li> <li> <p>End Session</p> </li> <li>Click \"End Session\"</li> <li>Review statistics</li> <li>View in activity history</li> </ol>"},{"location":"v2/user-guides/#task-guides","title":"Task Guides","text":""},{"location":"v2/user-guides/#import-canadian-electoral-data-nar","title":"Import Canadian Electoral Data (NAR)","text":"<ol> <li>Prepare Data</li> <li>Download NAR 2025 data</li> <li>Place in <code>/data</code> directory</li> <li> <p>Ensure Address + Location files present</p> </li> <li> <p>Import via Admin</p> </li> <li>Navigate to <code>/app/map/locations</code></li> <li>Click \"Import NAR\"</li> <li>Select province</li> <li>Apply filters</li> <li> <p>Start import</p> </li> <li> <p>Review Import</p> </li> <li>Check location count</li> <li>Verify geocoding</li> <li>Review quality dashboard</li> </ol>"},{"location":"v2/user-guides/#set-up-public-campaign-page","title":"Set Up Public Campaign Page","text":"<ol> <li>Create Campaign</li> <li>Configure targeting (federal/provincial)</li> <li>Write email template</li> <li> <p>Set to published</p> </li> <li> <p>Share URL</p> </li> <li>Copy public URL: <code>/campaigns/:id</code></li> <li>Share on social media</li> <li> <p>Embed in website</p> </li> <li> <p>Monitor Engagement</p> </li> <li>View email statistics</li> <li>Moderate responses</li> <li>Check response wall</li> </ol>"},{"location":"v2/user-guides/#configure-newsletter-sync","title":"Configure Newsletter Sync","text":"<ol> <li>Enable Listmonk</li> <li>Set <code>LISTMONK_SYNC_ENABLED=true</code></li> <li>Configure API credentials</li> <li> <p>Restart services</p> </li> <li> <p>Initialize Sync</p> </li> <li>Navigate to <code>/app/services/listmonk</code></li> <li>Click \"Test Connection\"</li> <li> <p>Click \"Sync Participants\"</p> </li> <li> <p>Manage Lists</p> </li> <li>View list statistics</li> <li>Configure sync settings</li> <li>Monitor sync status</li> </ol>"},{"location":"v2/user-guides/#set-up-public-tunnel","title":"Set Up Public Tunnel","text":"<ol> <li>Create Pangolin Account</li> <li>Sign up at pangolin.bnkserve.org</li> <li> <p>Generate API key</p> </li> <li> <p>Configure Tunnel</p> </li> <li>Navigate to <code>/app/services/pangolin</code></li> <li>Enter API key</li> <li>Follow setup wizard</li> <li> <p>Deploy Newt container</p> </li> <li> <p>Test Public Access</p> </li> <li>Visit public URL</li> <li>Verify subdomain routing</li> <li>Check SSL/TLS</li> </ol>"},{"location":"v2/user-guides/#create-landing-page","title":"Create Landing Page","text":"<ol> <li>Start New Page</li> <li>Navigate to <code>/app/pages</code></li> <li>Click \"New Page\"</li> <li> <p>Enter title and slug</p> </li> <li> <p>Design Page</p> </li> <li>Click \"Edit\"</li> <li>Use GrapesJS editor</li> <li>Drag blocks</li> <li>Customize content</li> <li> <p>Save (Ctrl+S)</p> </li> <li> <p>Publish</p> </li> <li>Set to published</li> <li>View at <code>/p/:slug</code></li> <li>Share URL</li> </ol>"},{"location":"v2/user-guides/#best-practices","title":"Best Practices","text":""},{"location":"v2/user-guides/#campaign-management","title":"Campaign Management","text":"<ul> <li>Use clear, action-oriented language</li> <li>Test email templates before launch</li> <li>Monitor response rates</li> <li>Moderate responses promptly</li> <li>Follow up with engaged supporters</li> </ul>"},{"location":"v2/user-guides/#field-organizing","title":"Field Organizing","text":"<ul> <li>Clean location data before import</li> <li>Create manageable cut sizes (100-200 locations)</li> <li>Assign volunteers to familiar areas</li> <li>Print walk sheets in advance</li> <li>Review canvass progress daily</li> </ul>"},{"location":"v2/user-guides/#content-creation","title":"Content Creation","text":"<ul> <li>Write mobile-responsive pages</li> <li>Use SEO-friendly titles and descriptions</li> <li>Test pages on multiple devices</li> <li>Keep content concise</li> <li>Include clear calls-to-action</li> </ul>"},{"location":"v2/user-guides/#system-administration","title":"System Administration","text":"<ul> <li>Change default passwords immediately</li> <li>Enable monitoring stack</li> <li>Set up automated backups</li> <li>Review security audit findings</li> <li>Keep services updated</li> </ul>"},{"location":"v2/user-guides/#mobile-usage","title":"Mobile Usage","text":""},{"location":"v2/user-guides/#volunteer-canvassing","title":"Volunteer Canvassing","text":"<p>Best on mobile devices:</p> <ul> <li>Full-screen map</li> <li>GPS tracking</li> <li>Touch-friendly controls</li> <li>Offline support (future)</li> </ul>"},{"location":"v2/user-guides/#admin-tasks","title":"Admin Tasks","text":"<p>Best on desktop:</p> <ul> <li>Content editing (GrapesJS, email templates)</li> <li>Data import/export</li> <li>Configuration</li> <li>Monitoring dashboards</li> </ul>"},{"location":"v2/user-guides/#keyboard-shortcuts","title":"Keyboard Shortcuts","text":""},{"location":"v2/user-guides/#page-editor","title":"Page Editor","text":"<ul> <li>Ctrl+S - Save page</li> <li>Ctrl+Z - Undo</li> <li>Ctrl+Y - Redo</li> </ul>"},{"location":"v2/user-guides/#general","title":"General","text":"<ul> <li>/ - Focus search (tables)</li> <li>Esc - Close modal/drawer</li> </ul>"},{"location":"v2/user-guides/#related-documentation","title":"Related Documentation","text":"<ul> <li>Admin Guide</li> <li>Campaign Manager Guide</li> <li>Map Organizer Guide</li> <li>Volunteer Guide</li> <li>Content Editor Guide</li> <li>Features Overview</li> <li>Troubleshooting</li> </ul>"},{"location":"v2/user-guides/admin-guide/","title":"Administrator Guide","text":""},{"location":"v2/user-guides/admin-guide/#overview","title":"Overview","text":"<p>The Administrator role is the highest-level role in Changemaker Lite. As an administrator, you have complete control over the platform, including:</p> <ul> <li>User management: Create, edit, suspend, and delete user accounts</li> <li>Campaign oversight: Manage all advocacy campaigns and moderate responses</li> <li>Location and mapping: Import locations, create territorial cuts, and organize canvassing efforts</li> <li>Volunteer coordination: Create shifts, manage signups, and monitor canvassing activity</li> <li>Site configuration: Configure global settings, themes, email, and feature toggles</li> <li>Content management: Create landing pages, edit email templates, and manage media library</li> <li>Monitoring: View queue status, geocoding quality, and system health</li> </ul> <p>This guide will walk you through all administrative functions in Changemaker Lite V2.</p>"},{"location":"v2/user-guides/admin-guide/#getting-started","title":"Getting Started","text":""},{"location":"v2/user-guides/admin-guide/#first-login","title":"First Login","text":"<p>When you first access Changemaker Lite, you'll log in at the admin portal URL:</p> <pre><code>https://app.cmlite.org\n</code></pre> <p>Or if running locally:</p> <pre><code>http://localhost:3000\n</code></pre> <p>Default credentials (change immediately after first login):</p> <ul> <li>Email: <code>admin@example.com</code></li> <li>Password: <code>Admin123!</code></li> </ul> <p>Security Critical</p> <p>The default password is publicly known. Change it immediately after your first login to prevent unauthorized access.</p> <p>Screenshot placeholder: Login page showing email/password fields and \"Remember me\" checkbox</p>"},{"location":"v2/user-guides/admin-guide/#dashboard-overview","title":"Dashboard Overview","text":"<p>After logging in, you'll see the Administrator Dashboard, which provides an at-a-glance overview of your platform:</p> <p>Key dashboard sections:</p> <ol> <li>Statistics Cards</li> <li>Total users (breakdown by role)</li> <li>Active campaigns</li> <li>Total locations</li> <li> <p>Active canvass sessions</p> </li> <li> <p>Recent Activity Feed</p> </li> <li>New user registrations</li> <li>Campaign responses</li> <li>Shift signups</li> <li> <p>Canvass visits</p> </li> <li> <p>Quick Actions</p> </li> <li>Create new campaign</li> <li>Import locations</li> <li>Create volunteer shift</li> <li> <p>View email queue</p> </li> <li> <p>System Health</p> </li> <li>API status</li> <li>Database connectivity</li> <li>Redis cache status</li> <li>Queue worker status</li> </ol> <p>Screenshot placeholder: Dashboard showing statistics cards, activity feed, and quick action buttons</p>"},{"location":"v2/user-guides/admin-guide/#changing-your-password","title":"Changing Your Password","text":"<p>Required First Step</p> <p>You must change the default password before performing any other administrative tasks.</p> <p>To change your password:</p> <ol> <li>Click your email address in the top-right corner</li> <li>Select \"Change Password\" from the dropdown</li> <li>Enter your current password</li> <li>Enter new password (must meet requirements below)</li> <li>Confirm new password</li> <li>Click \"Update Password\"</li> </ol> <p>Password requirements:</p> <ul> <li>Minimum 12 characters</li> <li>At least one uppercase letter (A-Z)</li> <li>At least one lowercase letter (a-z)</li> <li>At least one digit (0-9)</li> <li>Cannot reuse recent passwords</li> </ul> <p>Screenshot placeholder: Change password modal showing current/new password fields and requirements checklist</p>"},{"location":"v2/user-guides/admin-guide/#navigating-the-admin-interface","title":"Navigating the Admin Interface","text":"<p>The admin interface uses a sidebar navigation with the following sections:</p> <p>Main Navigation:</p> <ul> <li>Dashboard \u2014 Overview and quick actions</li> <li>Influence \u2014 Campaigns, responses, representatives, email queue</li> <li>Map \u2014 Locations, cuts, shifts, map settings, data quality</li> <li>Canvass \u2014 Dashboard, sessions, activity reports</li> <li>Content \u2014 Landing pages, email templates, media library</li> <li>Services \u2014 Listmonk, Pangolin, docs, integrations</li> <li>Observability \u2014 Monitoring, metrics, alerts</li> <li>Users \u2014 User management</li> <li>Settings \u2014 Global site settings</li> </ul> <p>Screenshot placeholder: Sidebar navigation showing expanded Influence and Map sections</p>"},{"location":"v2/user-guides/admin-guide/#user-management","title":"User Management","text":""},{"location":"v2/user-guides/admin-guide/#creating-users","title":"Creating Users","text":"<p>To create a new user:</p> <ol> <li>Navigate to Users in the sidebar</li> <li>Click \"Create User\" button (top-right)</li> <li>Fill in user details:</li> <li>Email: User's email address (must be unique)</li> <li>Name: User's full name</li> <li>Password: Temporary password (user should change on first login)</li> <li>Role: Select from dropdown (see roles below)</li> <li>Status: ACTIVE or SUSPENDED</li> <li>Click \"Create\"</li> </ol> <p>The new user will receive a welcome email (if email is configured) with their login credentials.</p> <p>Screenshot placeholder: Create User modal showing email, name, password, role dropdown, and status toggle</p>"},{"location":"v2/user-guides/admin-guide/#understanding-roles","title":"Understanding Roles","text":"<p>Changemaker Lite has five user roles with different permission levels:</p>"},{"location":"v2/user-guides/admin-guide/#1-super_admin-you","title":"1. SUPER_ADMIN (You)","text":"<ul> <li>Access: Everything</li> <li>Capabilities: All administrative functions, user management, site configuration</li> <li>Use case: Primary administrator(s)</li> </ul>"},{"location":"v2/user-guides/admin-guide/#2-influence_admin","title":"2. INFLUENCE_ADMIN","text":"<ul> <li>Access: Influence module only</li> <li>Capabilities:</li> <li>Create and manage campaigns</li> <li>Moderate responses</li> <li>View representative cache</li> <li>Monitor email queue</li> <li>Restrictions: Cannot manage users, locations, or site settings</li> <li>Use case: Campaign managers who don't need full admin access</li> </ul>"},{"location":"v2/user-guides/admin-guide/#3-map_admin","title":"3. MAP_ADMIN","text":"<ul> <li>Access: Map module only</li> <li>Capabilities:</li> <li>Import and manage locations</li> <li>Create cuts</li> <li>Organize shifts</li> <li>Monitor canvassing</li> <li>Restrictions: Cannot manage users, campaigns, or site settings</li> <li>Use case: Field organizers, volunteer coordinators</li> </ul>"},{"location":"v2/user-guides/admin-guide/#4-user","title":"4. USER","text":"<ul> <li>Access: Volunteer portal only</li> <li>Capabilities:</li> <li>View assigned shifts</li> <li>Start canvassing sessions</li> <li>Record door visits</li> <li>View own activity</li> <li>Restrictions: Cannot access admin areas</li> <li>Use case: Regular volunteers</li> </ul>"},{"location":"v2/user-guides/admin-guide/#5-temp","title":"5. TEMP","text":"<ul> <li>Access: Very limited, volunteer portal only</li> <li>Capabilities:</li> <li>Sign up for public shifts (creates TEMP account automatically)</li> <li>Cannot start canvassing sessions</li> <li>Restrictions: Cannot access most features until upgraded to USER</li> <li>Use case: Anonymous shift signups (converted to USER by admin)</li> </ul> <p>Role Upgrading</p> <p>You can upgrade TEMP users to USER role to give them full volunteer access. This is common after a volunteer attends their first shift.</p> <p>Screenshot placeholder: User list table showing users with different roles and color-coded role badges</p>"},{"location":"v2/user-guides/admin-guide/#managing-existing-users","title":"Managing Existing Users","text":"<p>The Users page shows all user accounts in a searchable, filterable table.</p> <p>Table columns:</p> <ul> <li>Name \u2014 User's full name</li> <li>Email \u2014 Login email</li> <li>Role \u2014 Current role (color-coded badge)</li> <li>Status \u2014 ACTIVE (green) or SUSPENDED (red)</li> <li>Last Login \u2014 Most recent login timestamp</li> <li>Created \u2014 Account creation date</li> <li>Actions \u2014 Edit, suspend/activate, delete</li> </ul> <p>Available filters:</p> <ul> <li>Search: Search by name or email</li> <li>Role filter: Show only specific roles</li> <li>Status filter: Active, suspended, or all</li> <li>Date range: Filter by creation date</li> </ul> <p>Screenshot placeholder: Users table with search bar, role filter dropdown, and action buttons</p>"},{"location":"v2/user-guides/admin-guide/#editing-users","title":"Editing Users","text":"<p>To edit a user:</p> <ol> <li>Click the Edit icon (pencil) in the Actions column</li> <li>Modify any of:</li> <li>Name</li> <li>Email (must remain unique)</li> <li>Role (change permissions)</li> <li>Status (activate/suspend)</li> <li>Click \"Save\"</li> </ol> <p>Email Changes</p> <p>Changing a user's email will require them to log in with the new email address. Notify them before making this change.</p>"},{"location":"v2/user-guides/admin-guide/#suspending-users","title":"Suspending Users","text":"<p>To temporarily disable a user account:</p> <ol> <li>Find the user in the table</li> <li>Click \"Suspend\" in the Actions column</li> <li>Confirm suspension</li> </ol> <p>Suspended users:</p> <ul> <li>Cannot log in</li> <li>Existing sessions are invalidated immediately</li> <li>Can be reactivated at any time</li> <li>Data and history are preserved</li> </ul> <p>When to suspend:</p> <ul> <li>Volunteer is temporarily unavailable</li> <li>Security concerns (investigate before deleting)</li> <li>User requests account pause</li> </ul> <p>Screenshot placeholder: Suspend confirmation dialog explaining effects</p>"},{"location":"v2/user-guides/admin-guide/#password-resets","title":"Password Resets","text":"<p>To reset a user's password:</p> <ol> <li>Edit the user</li> <li>Click \"Reset Password\"</li> <li>Choose one of:</li> <li>Generate temporary password (shown on screen, expires in 24 hours)</li> <li>Send reset email (user clicks link to set new password)</li> <li>Provide temporary password to user securely (not via email)</li> </ol> <p>Security Best Practice</p> <p>Always use \"Send reset email\" option when possible. Only generate temporary passwords for in-person support scenarios.</p>"},{"location":"v2/user-guides/admin-guide/#deleting-users","title":"Deleting Users","text":"<p>Permanent Action</p> <p>Deleting a user is permanent and cannot be undone. All associated data (canvass visits, responses, etc.) will be anonymized.</p> <p>To delete a user:</p> <ol> <li>Click the Delete icon (trash) in the Actions column</li> <li>Type the user's email to confirm</li> <li>Click \"Delete Permanently\"</li> </ol> <p>When deletion is appropriate:</p> <ul> <li>Duplicate accounts</li> <li>Test accounts in production</li> <li>User requests account deletion (GDPR compliance)</li> </ul> <p>Data handling on deletion:</p> <ul> <li>User account record is deleted</li> <li>Associated content (responses, visits) remains but user reference is nullified</li> <li>Email queue jobs remain (email address is preserved for audit)</li> </ul>"},{"location":"v2/user-guides/admin-guide/#viewing-login-activity","title":"Viewing Login Activity","text":"<p>To see recent login activity:</p> <ol> <li>Navigate to Users</li> <li>Check the \"Last Login\" column</li> <li>Click on a user to see detailed login history (if audit logging is enabled)</li> </ol> <p>Screenshot placeholder: User detail view showing login history table with timestamps and IP addresses</p>"},{"location":"v2/user-guides/admin-guide/#campaign-management","title":"Campaign Management","text":""},{"location":"v2/user-guides/admin-guide/#campaign-overview","title":"Campaign Overview","text":"<p>Campaigns are at the heart of the Influence module. A campaign allows citizens to:</p> <ol> <li>Enter their postal code</li> <li>Find their elected representatives</li> <li>Send advocacy emails</li> <li>Share their story on a public response wall</li> </ol> <p>As an administrator, you can create, configure, publish, and monitor campaigns.</p>"},{"location":"v2/user-guides/admin-guide/#creating-a-campaign","title":"Creating a Campaign","text":"<p>To create a new campaign:</p> <ol> <li>Navigate to Influence > Campaigns</li> <li>Click \"Create Campaign\" (top-right)</li> <li>Fill in the campaign form (see fields below)</li> <li>Click \"Create\"</li> </ol> <p>Required fields:</p> <p>Basic Information:</p> <ul> <li>Title: Campaign name (shown to public)</li> <li>Example: \"Protect Our Climate\"</li> <li>Slug: URL-friendly identifier (auto-generated from title)</li> <li>Example: <code>protect-our-climate</code></li> <li>Used in public URL: <code>/campaigns/protect-our-climate</code></li> <li>Description: Campaign overview (supports HTML)</li> <li>Shown on campaign listing page</li> <li>Recommended: 2-3 sentences</li> </ul> <p>Email Configuration:</p> <ul> <li>Email Subject: Subject line for advocacy emails</li> <li>Example: \"Please support climate action legislation\"</li> <li>Variables supported: <code>{{USER_NAME}}</code>, <code>{{REP_NAME}}</code></li> <li>Email Body: The email message citizens send</li> <li>HTML editor available</li> <li>Variables: <code>{{USER_NAME}}</code>, <code>{{USER_EMAIL}}</code>, <code>{{REP_NAME}}</code>, <code>{{REP_EMAIL}}</code>, <code>{{USER_MESSAGE}}</code></li> <li>Preview before publishing</li> </ul> <p>Targeting:</p> <ul> <li>Government Level: FEDERAL, PROVINCIAL, or MUNICIPAL</li> <li>Determines which representatives are looked up</li> <li>Can select multiple levels</li> </ul> <p>Screenshot placeholder: Create Campaign form showing title, slug, description, email subject, and body editor</p>"},{"location":"v2/user-guides/admin-guide/#understanding-feature-flags","title":"Understanding Feature Flags","text":"<p>Campaigns have 12 feature flags that control functionality:</p>"},{"location":"v2/user-guides/admin-guide/#core-features","title":"Core Features","text":"<ol> <li>Published</li> <li>Controls public visibility</li> <li>Unpublished campaigns only visible to admins</li> <li> <p>Toggle to launch/pause campaign</p> </li> <li> <p>Featured</p> </li> <li>Featured campaigns appear at top of listing page</li> <li>Use for high-priority campaigns</li> <li> <p>Limit to 2-3 featured campaigns</p> </li> <li> <p>Has Response Wall</p> </li> <li>Enables public response wall</li> <li>Citizens can share their story after emailing</li> <li> <p>Responses require admin approval (unless <code>auto_approve_responses</code>)</p> </li> <li> <p>Collect Phone Numbers</p> </li> <li>Adds optional phone number field</li> <li>Used for call-in campaigns</li> <li> <p>Numbers stored for admin use</p> </li> <li> <p>Track Calls</p> </li> <li>Adds \"I called my representative\" button</li> <li>Tracks call attempts separately from emails</li> <li>Good for blended campaigns</li> </ol>"},{"location":"v2/user-guides/admin-guide/#advanced-features","title":"Advanced Features","text":"<ol> <li>Require Verification</li> <li>Sends verification email before submitting</li> <li>Prevents spam and bot submissions</li> <li> <p>Recommended for public campaigns</p> </li> <li> <p>Auto Approve Responses</p> </li> <li>Response wall submissions appear immediately</li> <li>No admin moderation required</li> <li> <p>Only use for trusted campaigns</p> </li> <li> <p>Allow Anonymous</p> </li> <li>Citizens can submit without creating account</li> <li>Reduces friction but limits tracking</li> <li> <p>Good for privacy-sensitive topics</p> </li> <li> <p>Custom Recipients</p> </li> <li>Override representative lookup</li> <li>Send to specific email addresses</li> <li> <p>Use for non-government campaigns</p> </li> <li> <p>Show Progress Bar</p> <ul> <li>Displays email count goal and progress</li> <li>Motivates participation</li> <li>Requires setting <code>email_goal</code> field</li> </ul> </li> <li> <p>Disable After Date</p> <ul> <li>Automatically unpublish after specified date</li> <li>Good for time-sensitive campaigns</li> <li>Requires setting <code>disable_date</code> field</li> </ul> </li> <li> <p>Enable Comments</p> <ul> <li>Allow comments on response wall entries</li> <li>Creates discussion threads</li> <li>Requires moderation</li> </ul> </li> </ol> <p>Screenshot placeholder: Campaign feature flags showing toggles for all 12 flags with descriptive labels</p> <p>Recommended Defaults</p> <p>For most campaigns, enable: Published, Has Response Wall, Require Verification. Leave others off unless specifically needed.</p>"},{"location":"v2/user-guides/admin-guide/#configuring-email-template","title":"Configuring Email Template","text":"<p>The email template is what citizens send to their representatives. Make it:</p> <p>Effective email guidelines:</p> <ul> <li>Personal: Use variables like <code>{{USER_NAME}}</code> to personalize</li> <li>Clear: State the ask in first paragraph</li> <li>Specific: Reference specific legislation or issue</li> <li>Respectful: Professional tone, even if issue is urgent</li> <li>Actionable: Tell representatives exactly what you want them to do</li> </ul> <p>Example template:</p> <pre><code>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</code></pre> <p>Available variables:</p> <ul> <li><code>{{USER_NAME}}</code> \u2014 Citizen's full name</li> <li><code>{{USER_EMAIL}}</code> \u2014 Citizen's email address</li> <li><code>{{USER_PHONE}}</code> \u2014 Citizen's phone (if collected)</li> <li><code>{{REP_NAME}}</code> \u2014 Representative's name</li> <li><code>{{REP_EMAIL}}</code> \u2014 Representative's email</li> <li><code>{{REP_TITLE}}</code> \u2014 Representative's title (MP, MPP, Councillor)</li> <li><code>{{USER_MESSAGE}}</code> \u2014 Custom message from citizen (optional field)</li> </ul> <p>Screenshot placeholder: Email template editor showing subject and body fields with variable insertion dropdown</p>"},{"location":"v2/user-guides/admin-guide/#publishing-a-campaign","title":"Publishing a Campaign","text":"<p>Before publishing, verify:</p> <ul> <li> Email template is proofread (send test email to yourself)</li> <li> Feature flags are configured correctly</li> <li> Representative lookup is working (test with your postal code)</li> <li> Response wall moderation is ready (if enabled)</li> </ul> <p>To publish:</p> <ol> <li>Edit the campaign</li> <li>Toggle \"Published\" flag to ON</li> <li>Click \"Save\"</li> </ol> <p>The campaign is now live at <code>/campaigns/[slug]</code>.</p> <p>Promoting your campaign:</p> <ul> <li>Share direct link: <code>https://yourdomain.org/campaigns/protect-our-climate</code></li> <li>Embed in email newsletter</li> <li>Post on social media</li> <li>Add to landing page</li> </ul> <p>Screenshot placeholder: Published campaign card on public campaigns listing page</p>"},{"location":"v2/user-guides/admin-guide/#monitoring-email-sends","title":"Monitoring Email Sends","text":"<p>To view email statistics:</p> <ol> <li>Navigate to Influence > Campaigns</li> <li>Click \"Emails\" button in the Actions column for your campaign</li> </ol> <p>The Campaign Emails drawer shows:</p> <p>Statistics:</p> <ul> <li>Total emails sent</li> <li>Successful deliveries</li> <li>Failed deliveries</li> <li>Emails waiting in queue</li> </ul> <p>Email list table:</p> <ul> <li>Recipient name and email</li> <li>Status (PENDING, SENT, FAILED)</li> <li>Sent timestamp</li> <li>Representative targeted</li> <li>Error message (if failed)</li> </ul> <p>Actions:</p> <ul> <li>Retry failed: Re-queue failed emails</li> <li>Export CSV: Download full email list</li> </ul> <p>Screenshot placeholder: Campaign Emails drawer showing statistics cards and email list table</p>"},{"location":"v2/user-guides/admin-guide/#managing-the-email-queue","title":"Managing the Email Queue","text":"<p>The email queue processes advocacy emails asynchronously using BullMQ.</p> <p>To monitor queue health:</p> <ol> <li>Navigate to Influence > Email Queue</li> </ol> <p>Queue statistics:</p> <ul> <li>Waiting: Emails queued but not yet processing</li> <li>Active: Emails currently being sent</li> <li>Completed: Successfully sent emails (last 24 hours)</li> <li>Failed: Failed emails requiring retry</li> <li>Delayed: Scheduled for future sending</li> </ul> <p>Queue controls:</p> <ul> <li>Pause Queue: Stop processing new emails (emergencies only)</li> <li>Resume Queue: Restart after pause</li> <li>Clean Completed: Remove old completed jobs (frees memory)</li> <li>Retry Failed: Re-queue all failed emails</li> </ul> <p>Queue Pausing</p> <p>Only pause the queue during system maintenance or if email configuration is broken. Citizens expect immediate sends.</p> <p>Screenshot placeholder: Email Queue page showing statistics cards, job counts, and control buttons</p>"},{"location":"v2/user-guides/admin-guide/#moderating-responses","title":"Moderating Responses","text":"<p>If your campaign has \"Has Response Wall\" enabled, citizens can share their stories publicly.</p> <p>To moderate responses:</p> <ol> <li>Navigate to Influence > Responses</li> <li>Use filters to find pending responses</li> <li>Review each response</li> <li>Approve or reject</li> </ol> <p>Response filters:</p> <ul> <li>Campaign: Filter by specific campaign</li> <li>Status: PENDING, APPROVED, REJECTED</li> <li>Search: Search response text</li> <li>Date range: Filter by submission date</li> </ul> <p>Response table columns:</p> <ul> <li>Name: Citizen's name</li> <li>Campaign: Which campaign</li> <li>Status: Approval status (color-coded)</li> <li>Upvotes: Number of upvotes received</li> <li>Submitted: Submission date</li> <li>Actions: View, approve, reject, delete</li> </ul> <p>Screenshot placeholder: Responses table with filter controls and status badges</p> <p>To review a response:</p> <ol> <li>Click \"View\" in Actions column</li> <li>Read full response text</li> <li>Decide:</li> <li>Approve: Make public (appears on response wall)</li> <li>Reject: Hide from public (not deleted)</li> <li>Delete: Permanently remove</li> </ol> <p>Moderation guidelines:</p> <p>Approve responses that:</p> <ul> <li>Are authentic personal stories</li> <li>Relate to the campaign issue</li> <li>Use respectful language</li> <li>Add value to the public conversation</li> </ul> <p>Reject responses that:</p> <ul> <li>Contain profanity or hate speech</li> <li>Are spam or off-topic</li> <li>Violate privacy (include private information about others)</li> <li>Are duplicate submissions</li> </ul> <p>Screenshot placeholder: Response detail modal showing full text, citizen info, and approve/reject buttons</p>"},{"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":"<p>Locations represent physical addresses where canvassing occurs. Each location has:</p> <ul> <li>Address: Street address, city, province, postal code</li> <li>Coordinates: Latitude/longitude (from geocoding)</li> <li>Metadata: Building type, federal district, unit count</li> <li>Cut assignment: Which territorial cut it belongs to</li> <li>Canvass history: Visits, outcomes, support levels</li> </ul>"},{"location":"v2/user-guides/admin-guide/#importing-locations-from-csv","title":"Importing Locations from CSV","text":"<p>To import locations:</p> <ol> <li>Navigate to Map > Locations</li> <li>Click \"Import CSV\" button</li> <li>Upload CSV file</li> <li>Map CSV columns to location fields</li> <li>Click \"Import\"</li> </ol> <p>Required CSV columns:</p> <ul> <li>address \u2014 Full street address</li> <li>city \u2014 City name</li> <li>province \u2014 Province/state code (e.g., \"ON\", \"BC\")</li> <li>postalCode \u2014 Postal code (e.g., \"K1A 0B1\")</li> </ul> <p>Optional columns:</p> <ul> <li>latitude \u2014 Pre-geocoded latitude</li> <li>longitude \u2014 Pre-geocoded longitude</li> <li>buildingType \u2014 RESIDENTIAL, APARTMENT, BUSINESS</li> <li>unitCount \u2014 Number of units in building</li> <li>federalDistrict \u2014 Electoral district</li> <li>notes \u2014 Internal notes</li> </ul> <p>CSV example:</p> <pre><code>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</code></pre> <p>Excel to CSV</p> <p>If your data is in Excel, use \"Save As\" > \"CSV (Comma delimited)\" to export.</p> <p>Screenshot placeholder: CSV import dialog showing file upload, column mapping interface, and preview table</p>"},{"location":"v2/user-guides/admin-guide/#nar-import-canadian-electoral-data","title":"NAR Import (Canadian Electoral Data)","text":"<p>For Canadian campaigns, you can import official electoral data from Elections Canada NAR (National Address Register) files.</p> <p>To import NAR data:</p> <ol> <li>Navigate to Map > Locations</li> <li>Click \"NAR Import\" button</li> <li>Select province</li> <li>Choose NAR dataset (year)</li> <li>Apply filters:</li> <li>City filter (optional)</li> <li>Postal code filter (optional)</li> <li>Cut filter (assign to specific cut)</li> <li>Residential only (exclude commercial)</li> <li>Click \"Start Import\"</li> </ol> <p>The import runs server-side and can take several minutes for large provinces.</p> <p>NAR data includes:</p> <ul> <li>Precise civic addresses (from Address files)</li> <li>Geocoded coordinates (from Location files)</li> <li>Federal electoral districts</li> <li>Building use (residential, commercial, institutional)</li> </ul> <p>Screenshot placeholder: NAR Import modal showing province selector, dataset picker, and filter options</p> <p>NAR Data Source</p> <p>NAR data must be obtained from Elections Canada and placed in the <code>/data</code> directory on the server. Contact your system administrator.</p>"},{"location":"v2/user-guides/admin-guide/#geocoding-addresses","title":"Geocoding Addresses","text":"<p>Geocoding converts addresses to latitude/longitude coordinates for map display.</p> <p>Automatic geocoding:</p> <ul> <li>CSV imports without lat/lng are automatically geocoded</li> <li>NAR imports include pre-geocoded coordinates</li> <li>Manual location creation triggers geocoding</li> </ul> <p>Manual geocoding:</p> <ol> <li>Navigate to Map > Locations</li> <li>Filter for \"Ungeocoded\" locations</li> <li>Select locations to geocode</li> <li>Click \"Geocode Selected\" (bulk action)</li> </ol> <p>Geocoding providers (tried in order):</p> <ol> <li>Nominatim (OpenStreetMap) \u2014 Free, no API key required</li> <li>ArcGIS \u2014 Free tier, accurate for North America</li> <li>Photon \u2014 Free, Europe-focused</li> <li>Mapbox \u2014 Requires API key, very accurate</li> <li>Google \u2014 Requires API key, most accurate</li> <li>LocationIQ \u2014 Requires API key, Nominatim-based</li> </ol> <p>Geocoding Quality</p> <p>Check Map > Data Quality to review geocoding confidence levels. Re-geocode low-confidence addresses.</p> <p>Screenshot placeholder: Locations table with \"Geocode Selected\" button and geocoding status column</p>"},{"location":"v2/user-guides/admin-guide/#creating-cuts","title":"Creating Cuts","text":"<p>Cuts are geographic areas (wards, neighborhoods, districts) used to organize canvassing.</p> <p>To create a cut:</p> <ol> <li>Navigate to Map > Cuts</li> <li>Click the \"Map Drawing\" tab</li> <li>Click \"Start Drawing\"</li> <li>Click on the map to add polygon vertices</li> <li>Close the polygon (click near first point)</li> <li>Fill in cut details:</li> <li>Name: Cut identifier (e.g., \"Ward 5\", \"Downtown\")</li> <li>Category: WARD, NEIGHBORHOOD, DISTRICT, or CUSTOM</li> <li>Color: Display color on map</li> <li>Description: Internal notes</li> <li>Click \"Save Cut\"</li> </ol> <p>Cut best practices:</p> <ul> <li>Size: 200-500 locations per cut (manageable for canvassing)</li> <li>Boundaries: Use natural boundaries (roads, rivers, parks)</li> <li>Naming: Use official ward/district names when available</li> <li>Colors: Use distinct colors for adjacent cuts</li> </ul> <p>Screenshot placeholder: Cut drawing map interface showing polygon being drawn with vertex markers</p>"},{"location":"v2/user-guides/admin-guide/#assigning-locations-to-cuts","title":"Assigning Locations to Cuts","text":"<p>Automatic assignment (during cut creation):</p> <ul> <li>Locations inside polygon are automatically assigned</li> <li>Uses point-in-polygon algorithm</li> </ul> <p>Manual assignment:</p> <ol> <li>Navigate to Map > Locations</li> <li>Select locations to assign</li> <li>Choose \"Assign to Cut\" from bulk actions</li> <li>Select target cut</li> <li>Click \"Assign\"</li> </ol> <p>Viewing cut assignments:</p> <ul> <li>Location table has \"Cut\" column</li> <li>Filter locations by cut using dropdown</li> </ul> <p>Screenshot placeholder: Bulk action modal showing \"Assign to Cut\" with cut selector dropdown</p>"},{"location":"v2/user-guides/admin-guide/#managing-locations","title":"Managing Locations","text":"<p>To edit a location:</p> <ol> <li>Navigate to Map > Locations</li> <li>Click \"Edit\" in Actions column</li> <li>Modify fields:</li> <li>Address details</li> <li>Coordinates (manually adjust map pin)</li> <li>Building type</li> <li>Unit count</li> <li>Notes</li> <li>Cut assignment</li> <li>Click \"Save\"</li> </ol> <p>To delete locations:</p> <ol> <li>Select locations in table</li> <li>Choose \"Delete\" from bulk actions</li> <li>Confirm deletion</li> </ol> <p>Canvass History</p> <p>Deleting a location preserves associated canvass visits (visits are linked to coordinates, not location records).</p> <p>Screenshot placeholder: Edit Location modal showing address fields, map with draggable pin, and metadata fields</p>"},{"location":"v2/user-guides/admin-guide/#exporting-walk-sheets","title":"Exporting Walk Sheets","text":"<p>Walk sheets are printable lists of addresses for door-to-door canvassing.</p> <p>To generate a walk sheet:</p> <ol> <li>Navigate to Map > Locations</li> <li>Filter to specific cut</li> <li>Click \"Walk Sheet\" in the cut's action menu</li> </ol> <p>OR:</p> <ol> <li>Navigate to Canvass > Walk Sheet</li> <li>Select cut from dropdown</li> <li>Configure settings (see below)</li> <li>Click \"Print\"</li> </ol> <p>Walk sheet settings (from Map > Map Settings):</p> <ul> <li>Header text: Organization name, campaign info</li> <li>Instructions: How to use the walk sheet</li> <li>QR code: Include QR code linking to volunteer canvass map</li> <li>Sorting: Sort by street name or walking route</li> <li>Include map: Embed cut map on first page</li> </ul> <p>Walk sheet contents:</p> <ul> <li>Cut name and statistics</li> <li>QR code (volunteers scan to start canvass session)</li> <li>Location table:</li> <li>Address</li> <li>Unit count</li> <li>Last visit date</li> <li>Last outcome</li> <li>Notes field (blank for volunteers to fill)</li> </ul> <p>Screenshot placeholder: Walk sheet PDF preview showing header, QR code, and address table</p>"},{"location":"v2/user-guides/admin-guide/#volunteer-management","title":"Volunteer Management","text":""},{"location":"v2/user-guides/admin-guide/#creating-shifts","title":"Creating Shifts","text":"<p>Shifts are scheduled volunteer canvassing sessions assigned to specific cuts.</p> <p>To create a shift:</p> <ol> <li>Navigate to Map > Shifts</li> <li>Click \"Create Shift\"</li> <li>Fill in shift details:</li> <li>Title: Shift name (e.g., \"Saturday Morning Canvass - Ward 5\")</li> <li>Description: Additional details for volunteers</li> <li>Start Time: Shift start date and time</li> <li>End Time: Shift end date and time</li> <li>Cut: Which cut to canvass (optional, but recommended)</li> <li>Max Signups: Capacity limit (0 = unlimited)</li> <li>Meeting Location: Where volunteers should meet</li> <li>Click \"Create\"</li> </ol> <p>Screenshot placeholder: Create Shift modal showing date/time picker, cut selector, and capacity field</p> <p>Cut Assignment</p> <p>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.</p>"},{"location":"v2/user-guides/admin-guide/#managing-shift-signups","title":"Managing Shift Signups","text":"<p>To view shift signups:</p> <ol> <li>Navigate to Map > Shifts</li> <li>Click \"Signups\" in Actions column</li> </ol> <p>The signups drawer shows:</p> <ul> <li>Total signups vs capacity</li> <li>Signup list: Name, email, role, signup date</li> <li>Actions: Remove signup, upgrade TEMP users to USER</li> </ul> <p>Signup sources:</p> <ul> <li>Public signup form: <code>/shifts</code> page (creates TEMP users)</li> <li>Admin-created: You manually add volunteers</li> <li>Volunteer portal: USER-role volunteers sign up themselves</li> </ul> <p>Screenshot placeholder: Shift Signups drawer showing capacity gauge and signup list table</p>"},{"location":"v2/user-guides/admin-guide/#emailing-shift-volunteers","title":"Emailing Shift Volunteers","text":"<p>To email all volunteers in a shift:</p> <ol> <li>Navigate to Map > Shifts</li> <li>Click \"Signups\" for the shift</li> <li>Click \"Email All\" button</li> <li>Compose email:</li> <li>Subject: Email subject line</li> <li>Body: Message (supports HTML)</li> <li>Variables: Use <code>{{NAME}}</code>, <code>{{SHIFT_TITLE}}</code>, <code>{{SHIFT_START}}</code></li> <li>Click \"Send\"</li> </ol> <p>Common email scenarios:</p> <ul> <li>Reminder: Day before shift</li> <li>Cancellation: Weather or other issues</li> <li>Location change: Meeting point updated</li> <li>Follow-up: Thank you after shift</li> </ul> <p>Screenshot placeholder: Email Volunteers modal showing subject, body editor, and variable insertion buttons</p>"},{"location":"v2/user-guides/admin-guide/#monitoring-canvass-sessions","title":"Monitoring Canvass Sessions","text":"<p>To view active canvass sessions:</p> <ol> <li>Navigate to Canvass > Dashboard</li> </ol> <p>The dashboard shows:</p> <p>Statistics cards:</p> <ul> <li>Active sessions: Currently in progress</li> <li>Total visits today: Doors knocked</li> <li>Completed sessions: Finished today</li> <li>Average session duration</li> </ul> <p>Activity feed:</p> <ul> <li>Real-time visit stream</li> <li>Shows: Volunteer name, address, outcome, timestamp</li> <li>Updates every 30 seconds</li> </ul> <p>Cut progress table:</p> <ul> <li>Progress by cut (% of locations visited)</li> <li>Session count per cut</li> <li>Visit count per cut</li> </ul> <p>Leaderboard:</p> <ul> <li>Top volunteers by visit count</li> <li>Session count</li> <li>Success rate (SPOKE_WITH outcomes)</li> </ul> <p>Screenshot placeholder: Canvass Dashboard showing stats cards, activity feed, and leaderboard</p>"},{"location":"v2/user-guides/admin-guide/#viewing-canvass-activity-reports","title":"Viewing Canvass Activity Reports","text":"<p>To see detailed canvassing data:</p> <ol> <li>Navigate to Canvass > Dashboard</li> <li>Use filters:</li> <li>Date range: Last 7 days, last 30 days, custom</li> <li>Cut: Specific cut or all</li> <li>Volunteer: Specific volunteer or all</li> <li>Outcome: Filter by visit outcome</li> </ol> <p>Exportable reports:</p> <ul> <li>Visit history CSV: All visits with outcomes, notes, timestamps</li> <li>Support levels CSV: LEVEL_1 through LEVEL_4 breakdown</li> <li>Session summary CSV: Session duration, visit count, volunteer info</li> </ul> <p>Screenshot placeholder: Activity report filters and export buttons</p>"},{"location":"v2/user-guides/admin-guide/#site-configuration","title":"Site Configuration","text":""},{"location":"v2/user-guides/admin-guide/#site-settings","title":"Site Settings","text":"<p>To configure global site settings:</p> <ol> <li>Navigate to Settings (gear icon in sidebar)</li> </ol> <p>Available settings:</p> <p>Branding:</p> <ul> <li>Site Name: Your organization name</li> <li>Site URL: Public website URL</li> <li>Logo URL: URL to your logo image</li> <li>Primary Color: Brand color (hex code)</li> <li>Secondary Color: Accent color</li> </ul> <p>Email Configuration:</p> <ul> <li>From Name: Sender name for system emails</li> <li>From Email: Sender email address</li> <li>SMTP Host: Email server hostname</li> <li>SMTP Port: Usually 587 (TLS) or 465 (SSL)</li> <li>SMTP Username: SMTP authentication username</li> <li>SMTP Password: SMTP authentication password</li> <li>Test Mode: Send to MailHog instead of real SMTP (dev only)</li> </ul> <p>Representative API:</p> <ul> <li>Represent API Base URL: Usually <code>https://represent.opennorth.ca</code></li> <li>API Key: If required by provider</li> <li>Cache TTL: How long to cache representative data (hours)</li> </ul> <p>Feature Toggles:</p> <ul> <li>Enable Media Features: Enable video library and media management</li> <li>Enable Listmonk Sync: Sync contacts to Listmonk newsletter platform</li> <li>Allow Public Shift Signup: Anyone can sign up for shifts (creates TEMP users)</li> <li>Require Email Verification: Campaign responses require email confirmation</li> </ul> <p>Screenshot placeholder: Settings page showing branding, email, and feature toggle sections</p> <p>Test Email Configuration</p> <p>After changing SMTP settings, click \"Send Test Email\" to verify configuration before publishing campaigns.</p>"},{"location":"v2/user-guides/admin-guide/#map-settings","title":"Map Settings","text":"<p>To configure map defaults:</p> <ol> <li>Navigate to Map > Map Settings</li> </ol> <p>Map Configuration:</p> <ul> <li>Default Center: Latitude/longitude for map center</li> <li>Used on public map and admin map</li> <li>Usually your city center</li> <li>Default Zoom: Zoom level (1-18)</li> <li>12 = city-wide view</li> <li>15 = neighborhood view</li> <li>Enable Fullscreen: Allow fullscreen button on public map</li> <li>Enable Geolocation: Allow \"Find My Location\" button</li> </ul> <p>Walk Sheet Configuration:</p> <ul> <li>Header Text: Appears at top of walk sheets</li> <li>Footer Text: Appears at bottom</li> <li>Include QR Code: Add QR code linking to volunteer map</li> <li>QR Code Size: Small, medium, or large</li> <li>Instructions: Text explaining how to use walk sheet</li> </ul> <p>Screenshot placeholder: Map Settings page showing map center picker and walk sheet config</p>"},{"location":"v2/user-guides/admin-guide/#feature-toggles","title":"Feature Toggles","text":"<p>Feature toggles allow you to enable/disable major platform features without code changes.</p> <p>To manage feature toggles:</p> <ol> <li>Navigate to Settings</li> <li>Scroll to Feature Toggles section</li> <li>Toggle features on/off</li> <li>Click \"Save\"</li> </ol> <p>Available toggles:</p> <p>ENABLE_MEDIA_FEATURES</p> <ul> <li>Enables Media Library and video management</li> <li>Shows Media menu in sidebar</li> <li>Allows video uploads and public media gallery</li> <li>Requires media-api service running</li> </ul> <p>ENABLE_LISTMONK_SYNC</p> <ul> <li>Enables newsletter integration</li> <li>Syncs campaign participants to Listmonk lists</li> <li>Shows Listmonk menu in sidebar</li> <li>Requires Listmonk service configured</li> </ul> <p>ALLOW_PUBLIC_SHIFT_SIGNUP</p> <ul> <li>Public can sign up for shifts at <code>/shifts</code></li> <li>Creates TEMP user accounts automatically</li> <li>Shows shifts on public pages</li> <li>Disable for invitation-only volunteering</li> </ul> <p>REQUIRE_EMAIL_VERIFICATION</p> <ul> <li>Campaign responses require email verification</li> <li>Prevents spam and fake submissions</li> <li>Sends verification link before recording response</li> <li>Recommended for public campaigns</li> </ul> <p>Screenshot placeholder: Feature Toggles section showing four toggles with descriptions</p> <p>Media Features</p> <p>Enabling media features requires the <code>media-api</code> Docker container to be running. Check with your system administrator.</p>"},{"location":"v2/user-guides/admin-guide/#email-templates","title":"Email Templates","text":""},{"location":"v2/user-guides/admin-guide/#understanding-email-templates","title":"Understanding Email Templates","text":"<p>Changemaker Lite uses email templates for system-generated emails:</p> <p>System templates:</p> <ul> <li>Welcome Email: Sent to new users</li> <li>Password Reset: Sent when user requests password reset</li> <li>Shift Confirmation: Sent when volunteer signs up for shift</li> <li>Shift Reminder: Sent day before shift</li> <li>Response Verification: Sent to verify campaign response</li> </ul> <p>Custom templates:</p> <ul> <li>You can create custom templates for specific needs</li> <li>Use in shift emails, campaign follow-ups, etc.</li> </ul>"},{"location":"v2/user-guides/admin-guide/#editing-templates","title":"Editing Templates","text":"<p>To edit an email template:</p> <ol> <li>Navigate to Content > Email Templates</li> <li>Click \"Edit\" for the template</li> <li>Modify:</li> <li>Subject: Email subject line</li> <li>HTML Body: Rich email content</li> <li>Plain Text Body: Fallback for text-only clients</li> <li>Use variables (e.g., <code>{{USER_NAME}}</code>, <code>{{SHIFT_TITLE}}</code>)</li> <li>Click \"Preview\" to see rendered email</li> <li>Click \"Save\"</li> </ol> <p>Screenshot placeholder: Email Template Editor showing subject field, HTML editor, and variable buttons</p>"},{"location":"v2/user-guides/admin-guide/#available-variables","title":"Available Variables","text":"<p>Templates support variable interpolation:</p> <p>User variables:</p> <ul> <li><code>{{USER_NAME}}</code> \u2014 User's full name</li> <li><code>{{USER_EMAIL}}</code> \u2014 User's email address</li> </ul> <p>Shift variables:</p> <ul> <li><code>{{SHIFT_TITLE}}</code> \u2014 Shift name</li> <li><code>{{SHIFT_START}}</code> \u2014 Start date/time</li> <li><code>{{SHIFT_END}}</code> \u2014 End date/time</li> <li><code>{{SHIFT_LOCATION}}</code> \u2014 Meeting location</li> <li><code>{{SHIFT_CUT}}</code> \u2014 Cut name</li> </ul> <p>Campaign variables:</p> <ul> <li><code>{{CAMPAIGN_TITLE}}</code> \u2014 Campaign name</li> <li><code>{{CAMPAIGN_URL}}</code> \u2014 Link to campaign page</li> </ul> <p>System variables:</p> <ul> <li><code>{{SITE_NAME}}</code> \u2014 Your organization name</li> <li><code>{{SITE_URL}}</code> \u2014 Website URL</li> </ul> <p>Screenshot placeholder: Variable reference table in template editor sidebar</p>"},{"location":"v2/user-guides/admin-guide/#testing-templates","title":"Testing Templates","text":"<p>To test an email template:</p> <ol> <li>Edit the template</li> <li>Click \"Send Test Email\"</li> <li>Enter your email address</li> <li>Click \"Send\"</li> </ol> <p>You'll receive the email with sample data filled in for variables.</p> <p>Always Test</p> <p>Test templates before using them in production. Check both HTML and plain text versions.</p>"},{"location":"v2/user-guides/admin-guide/#media-library","title":"Media Library","text":"<p>Optional Feature</p> <p>Media features must be enabled via Settings > Feature Toggles > ENABLE_MEDIA_FEATURES. Requires media-api service.</p>"},{"location":"v2/user-guides/admin-guide/#uploading-videos","title":"Uploading Videos","text":"<p>To upload a video:</p> <ol> <li>Navigate to Content > Media > Library</li> <li>Click \"Upload Video\"</li> <li>Either:</li> <li>Drag and drop video file</li> <li>Click to browse and select file</li> <li>Fill in metadata:</li> <li>Title: Video title</li> <li>Description: Video description</li> <li>Producer: Organization or creator</li> <li>Creator: Individual creator/director</li> <li>Tags: Comma-separated tags</li> <li>Directory: Organize into folders</li> <li>Click \"Upload\"</li> </ol> <p>Supported formats:</p> <ul> <li>MP4 (recommended)</li> <li>MOV</li> <li>AVI</li> <li>MKV</li> <li>WebM</li> <li>M4V</li> <li>FLV</li> </ul> <p>File size limit: 10 GB per file</p> <p>Screenshot placeholder: Upload Video modal showing drag-drop area, metadata form, and progress bar</p>"},{"location":"v2/user-guides/admin-guide/#automatic-metadata-extraction","title":"Automatic Metadata Extraction","text":"<p>When you upload a video, the system automatically extracts:</p> <ul> <li>Duration: Length in seconds</li> <li>Dimensions: Width x height in pixels</li> <li>Orientation: PORTRAIT, LANDSCAPE, or SQUARE</li> <li>Quality: SD, HD, FULL_HD, or 4K</li> <li>Has Audio: Boolean</li> <li>File Size: Bytes</li> </ul> <p>This metadata is used for filtering and organizing videos.</p>"},{"location":"v2/user-guides/admin-guide/#organizing-the-library","title":"Organizing the Library","text":"<p>Directory structure:</p> <ul> <li>Create directories to organize videos</li> <li>Directories are simple text paths (e.g., \"events/2024\", \"testimonials\")</li> <li>Set directory when uploading or editing</li> </ul> <p>Filtering videos:</p> <ul> <li>Search: Search title, description, tags</li> <li>Directory: Filter by directory</li> <li>Quality: Filter by SD, HD, etc.</li> <li>Orientation: Portrait, landscape, square</li> <li>Locked: Show only locked or unlocked</li> </ul> <p>Sorting:</p> <ul> <li>Upload date (newest first)</li> <li>Title (A-Z)</li> <li>Duration (shortest first)</li> </ul> <p>Screenshot placeholder: Media Library showing directory tree, filters, and video grid</p>"},{"location":"v2/user-guides/admin-guide/#sharing-videos-publicly","title":"Sharing Videos Publicly","text":"<p>To make videos public:</p> <ol> <li>Navigate to Content > Media > Shared Media</li> <li>Select videos from library</li> <li>Choose category:</li> <li>TESTIMONIAL</li> <li>EVENT</li> <li>EDUCATIONAL</li> <li>PROMOTIONAL</li> <li>Click \"Share\"</li> </ol> <p>Shared videos appear on the public media gallery at <code>/media</code>.</p> <p>To unshare videos:</p> <ol> <li>Go to Shared Media</li> <li>Select videos</li> <li>Click \"Unshare\"</li> </ol> <p>Screenshot placeholder: Shared Media page showing category filter and share/unshare buttons</p>"},{"location":"v2/user-guides/admin-guide/#locking-videos","title":"Locking Videos","text":"<p>Locked videos cannot be deleted or moved. Use locks to protect important content.</p> <p>To lock a video:</p> <ol> <li>Select video in library</li> <li>Click \"Lock\" (padlock icon)</li> </ol> <p>To unlock:</p> <ol> <li>Select locked video</li> <li>Click \"Unlock\"</li> </ol> <p>Lock Before Sharing</p> <p>Lock videos before sharing publicly to prevent accidental deletion.</p>"},{"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":"<p>To monitor the email queue:</p> <ol> <li>Navigate to Influence > Email Queue</li> </ol> <p>Key metrics:</p> <ul> <li>Waiting: Emails queued for sending</li> <li>High number = slow processing (check SMTP)</li> <li>Active: Currently processing</li> <li>Should be 1-5 (concurrent workers)</li> <li>Completed: Sent in last 24 hours</li> <li>Failed: Delivery failures</li> <li>Click \"View Failed\" to see error messages</li> </ul> <p>Queue health indicators:</p> <ul> <li>Green: < 50 waiting, < 5 failed</li> <li>Yellow: 50-200 waiting, 5-20 failed</li> <li>Red: > 200 waiting, > 20 failed</li> </ul> <p>Screenshot placeholder: Email Queue dashboard showing job counts with color-coded health indicators</p>"},{"location":"v2/user-guides/admin-guide/#geocoding-quality-dashboard","title":"Geocoding Quality Dashboard","text":"<p>To review geocoding quality:</p> <ol> <li>Navigate to Map > Data Quality</li> </ol> <p>Quality metrics:</p> <ul> <li>Total locations: All location records</li> <li>Geocoded: Have lat/lng coordinates</li> <li>Ungeocoded: Missing coordinates</li> <li>Low confidence: Confidence < 0.5</li> <li>Medium confidence: 0.5-0.8</li> <li>High confidence: > 0.8</li> </ul> <p>Quality breakdown:</p> <ul> <li>Provider distribution: Which geocoding service was used</li> <li>Confidence histogram: Distribution of confidence scores</li> <li>Error analysis: Common geocoding failures</li> </ul> <p>Actions:</p> <ul> <li>Re-geocode low confidence: Retry with different provider</li> <li>Export ungeocoded: CSV of failed addresses</li> <li>Manual review: Edit addresses and re-geocode</li> </ul> <p>Screenshot placeholder: Data Quality Dashboard showing geocoding statistics and confidence distribution chart</p>"},{"location":"v2/user-guides/admin-guide/#canvass-completion-statistics","title":"Canvass Completion Statistics","text":"<p>To view canvass progress:</p> <ol> <li>Navigate to Canvass > Dashboard</li> </ol> <p>Completion metrics:</p> <ul> <li>Locations visited: Total unique addresses visited</li> <li>Visit rate: Visits per day/week</li> <li>Completion by cut: % of each cut visited</li> <li>Outcome breakdown: % NOT_HOME, REFUSED, SPOKE_WITH, etc.</li> </ul> <p>Support level analysis:</p> <ul> <li>LEVEL_1 (Strong support): Count and percentage</li> <li>LEVEL_2 (Leaning support): Count and percentage</li> <li>LEVEL_3 (Undecided): Count and percentage</li> <li>LEVEL_4 (Opposition): Count and percentage</li> </ul> <p>Volunteer performance:</p> <ul> <li>Sessions per volunteer: Distribution histogram</li> <li>Visits per volunteer: Leaderboard</li> <li>Average session duration: Time spent canvassing</li> </ul> <p>Screenshot placeholder: Canvass statistics showing completion gauges, outcome pie chart, and support level breakdown</p>"},{"location":"v2/user-guides/admin-guide/#observability-dashboard","title":"Observability Dashboard","text":"<p>To monitor system health:</p> <ol> <li>Navigate to Observability</li> </ol> <p>The observability dashboard has three tabs:</p>"},{"location":"v2/user-guides/admin-guide/#metrics-tab","title":"Metrics Tab","text":"<ul> <li>Custom metrics: 12 <code>cm_*</code> Prometheus metrics</li> <li>API uptime</li> <li>Request counts</li> <li>Email queue size</li> <li>Active sessions</li> <li>Geocoding success rate</li> <li>HTTP metrics: Request duration, status codes</li> <li>System metrics: CPU, memory, disk</li> </ul> <p>Screenshot placeholder: Metrics tab showing API uptime gauge and request count graph</p>"},{"location":"v2/user-guides/admin-guide/#dashboards-tab","title":"Dashboards Tab","text":"<ul> <li>Links to Grafana dashboards:</li> <li>API Health (uptime, response times, error rates)</li> <li>Queue Monitoring (email queue, geocoding queue)</li> <li>Canvassing Activity (sessions, visits, outcomes)</li> <li>Click dashboard name to open in Grafana</li> </ul> <p>Screenshot placeholder: Dashboards tab showing three dashboard cards with \"Open\" buttons</p>"},{"location":"v2/user-guides/admin-guide/#alerts-tab","title":"Alerts Tab","text":"<ul> <li>Active alerts: Currently firing alerts</li> <li>Alert history: Recent resolved alerts</li> <li>Alert rules: Configured thresholds</li> <li>Silence alerts: Temporarily mute alerts</li> </ul> <p>Common alerts:</p> <ul> <li>API Down: API not responding</li> <li>High Error Rate: > 5% requests failing</li> <li>Queue Backed Up: > 1000 emails waiting</li> <li>Disk Space Low: < 10% free space</li> </ul> <p>Screenshot placeholder: Alerts tab showing active alert for \"Queue Backed Up\" with severity and details</p>"},{"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":"<p>Symptoms: \"Invalid credentials\" error</p> <p>Solutions:</p> <ol> <li>Verify email address: Check for typos, spaces</li> <li>Try password reset: Use \"Forgot Password\" link</li> <li>Check account status: Ask another admin if account is suspended</li> <li>Check browser console: Look for API errors</li> </ol>"},{"location":"v2/user-guides/admin-guide/#issue-emails-not-sending","title":"Issue: Emails Not Sending","text":"<p>Symptoms: Emails stuck in \"Waiting\" status</p> <p>Solutions:</p> <ol> <li>Check SMTP configuration:</li> <li>Navigate to Settings</li> <li>Verify SMTP host, port, username, password</li> <li>Click \"Send Test Email\"</li> <li>Check email queue:</li> <li>Navigate to Influence > Email Queue</li> <li>Look for error messages in failed jobs</li> <li>Check email test mode:</li> <li>If <code>EMAIL_TEST_MODE=true</code>, emails go to MailHog (not real recipients)</li> <li>Change in environment settings</li> <li>Restart queue worker:</li> <li>Ask system administrator to restart api service</li> </ol>"},{"location":"v2/user-guides/admin-guide/#issue-csv-import-fails","title":"Issue: CSV Import Fails","text":"<p>Symptoms: Error during CSV upload</p> <p>Solutions:</p> <ol> <li>Check CSV format:</li> <li>Must be valid CSV (comma-separated)</li> <li>First row must be headers</li> <li>Required columns: address, city, province, postalCode</li> <li>Check file encoding:</li> <li>Use UTF-8 encoding</li> <li>Excel users: \"Save As\" > \"CSV UTF-8\"</li> <li>Check file size:</li> <li>Maximum 10,000 rows per import</li> <li>Split large files</li> <li>Check for special characters:</li> <li>Remove emoji, unusual symbols</li> <li>Use standard quotes (\"not \"\" or '')</li> </ol>"},{"location":"v2/user-guides/admin-guide/#issue-geocoding-fails","title":"Issue: Geocoding Fails","text":"<p>Symptoms: Addresses remain ungeocoded after import</p> <p>Solutions:</p> <ol> <li>Check address format:</li> <li>Include full civic address</li> <li>Include city and postal code</li> <li>Use standard abbreviations (St, Ave, Rd)</li> <li>Check geocoding providers:</li> <li>Navigate to Map > Data Quality</li> <li>See which providers are responding</li> <li>Try manual geocoding:</li> <li>Edit location</li> <li>Click and drag map pin to correct position</li> <li>Save</li> <li>Use NAR data (Canada only):</li> <li>NAR import includes pre-geocoded coordinates</li> <li>More reliable than automatic geocoding</li> </ol>"},{"location":"v2/user-guides/admin-guide/#issue-map-not-loading","title":"Issue: Map Not Loading","text":"<p>Symptoms: Blank map or loading spinner</p> <p>Solutions:</p> <ol> <li>Check browser console: Look for JavaScript errors</li> <li>Check internet connection: Map tiles require network</li> <li>Try different browser: Test in Chrome, Firefox</li> <li>Clear browser cache: Hard refresh (Ctrl+Shift+R)</li> <li>Check locations:</li> <li>Navigate to Map > Locations</li> <li>Verify locations have coordinates</li> <li>At least one location needed to display map</li> </ol>"},{"location":"v2/user-guides/admin-guide/#issue-campaign-not-appearing-publicly","title":"Issue: Campaign Not Appearing Publicly","text":"<p>Symptoms: Campaign visible in admin but not on <code>/campaigns</code></p> <p>Solutions:</p> <ol> <li>Check \"Published\" flag:</li> <li>Edit campaign</li> <li>Ensure \"Published\" toggle is ON</li> <li>Save</li> <li>Check URL:</li> <li>Campaign URL is <code>/campaigns/[slug]</code></li> <li>Slug is auto-generated from title</li> <li>Must be unique</li> <li>Clear browser cache: Public pages may be cached</li> <li>Check representative lookup:</li> <li>Test with your postal code</li> <li>If lookup fails, campaign won't display form</li> </ol>"},{"location":"v2/user-guides/admin-guide/#issue-volunteer-cannot-start-canvass-session","title":"Issue: Volunteer Cannot Start Canvass Session","text":"<p>Symptoms: Error when volunteer clicks \"Start Canvassing\"</p> <p>Solutions:</p> <ol> <li>Check shift assignment:</li> <li>Navigate to Map > Shifts</li> <li>Verify shift has a cut assigned</li> <li>Shifts without cuts cannot be canvassed</li> <li>Check volunteer role:</li> <li>Navigate to Users</li> <li>Verify volunteer is USER role (not TEMP)</li> <li>Upgrade TEMP users to USER</li> <li>Check cut locations:</li> <li>Navigate to Map > Cuts</li> <li>Verify cut has locations assigned</li> <li>Empty cuts cannot be canvassed</li> <li>Check for existing session:</li> <li>Volunteer may have abandoned session</li> <li>Ask admin to close abandoned session</li> </ol>"},{"location":"v2/user-guides/admin-guide/#getting-help","title":"Getting Help","text":"<p>Documentation:</p> <ul> <li>Feature docs: <code>/docs/v2/features/</code> (detailed feature guides)</li> <li>API reference: <code>/docs/v2/api/</code> (API endpoint documentation)</li> <li>User guides: <code>/docs/v2/user-guides/</code> (this guide and others)</li> <li>Deployment: <code>/docs/v2/deployment/</code> (server setup, Docker, backups)</li> </ul> <p>Support channels:</p> <ul> <li>GitHub Issues: Report bugs, request features</li> <li>Community Forum: Ask questions, share tips</li> <li>Email Support: Contact your system administrator</li> </ul> <p>Before asking for help:</p> <ol> <li>Check browser console for errors (F12)</li> <li>Try in different browser</li> <li>Check server logs (if you have access)</li> <li>Document steps to reproduce issue</li> </ol>"},{"location":"v2/user-guides/admin-guide/#related-documentation","title":"Related Documentation","text":"<ul> <li>Volunteer Guide: Guide for volunteers using the canvassing portal</li> <li>Campaign Manager Guide: Deep dive on campaign strategy and management</li> <li>Map Organizer Guide: Advanced location and territory management</li> <li>Content Editor Guide: Landing pages and media library</li> <li>Influence Module: Technical details on campaigns and email sending</li> <li>Map Module: Technical details on geocoding and canvassing</li> <li>API Reference: REST API documentation for integrations</li> </ul> <p>Last updated: February 2026 (V2 complete)</p>"},{"location":"v2/user-guides/campaign-manager-guide/","title":"Campaign Manager Guide","text":""},{"location":"v2/user-guides/campaign-manager-guide/#overview","title":"Overview","text":"<p>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:</p> <ul> <li>Plan effective campaigns: Set goals, define targets, craft messaging</li> <li>Configure campaigns: Set up email templates, feature flags, and targeting</li> <li>Launch campaigns: Publish and promote to maximize participation</li> <li>Monitor performance: Track email sends, response rates, and engagement</li> <li>Optimize results: A/B test messaging, improve conversion, encourage responses</li> <li>Moderate content: Review and approve response wall submissions</li> </ul> <p>Whether you're running a small local campaign or a national advocacy push, this guide provides strategies and best practices for success.</p>"},{"location":"v2/user-guides/campaign-manager-guide/#understanding-campaign-roles","title":"Understanding Campaign Roles","text":"<p>You may have one of two roles that allow campaign management:</p>"},{"location":"v2/user-guides/campaign-manager-guide/#super_admin","title":"SUPER_ADMIN","text":"<ul> <li>Access: Full platform access</li> <li>Capabilities: All campaign functions plus user management, site settings, etc.</li> <li>Use case: Primary administrator</li> </ul>"},{"location":"v2/user-guides/campaign-manager-guide/#influence_admin","title":"INFLUENCE_ADMIN","text":"<ul> <li>Access: Influence module only</li> <li>Capabilities:</li> <li>Create and edit campaigns</li> <li>Moderate responses</li> <li>Monitor email queue</li> <li>View representative cache</li> <li>Restrictions: Cannot manage users, locations, or site settings</li> <li>Use case: Dedicated campaign manager without full admin access</li> </ul> <p>Role Specialization</p> <p>If you only manage campaigns (not volunteers or locations), ask for INFLUENCE_ADMIN role. This keeps the interface focused on your work.</p>"},{"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":"<p>Before creating a campaign in the system, clarify your objectives:</p> <p>Advocacy goals:</p> <ol> <li>Awareness: Educate the public about an issue</li> <li>Pressure: Generate constituent contact to influence decision-makers</li> <li>Mobilization: Build a list of supporters for future action</li> <li>Visibility: Demonstrate public support through response wall</li> </ol> <p>Measurable targets:</p> <ul> <li>Email goal: How many emails do you want sent?</li> <li>Example: \"1,000 emails to MPs by end of month\"</li> <li>Response goal: How many public responses?</li> <li>Example: \"100 personal stories shared on response wall\"</li> <li>Conversion rate: What % of visitors should take action?</li> <li>Benchmark: 5-15% is typical for advocacy campaigns</li> <li>Timeline: When does the campaign start/end?</li> <li>Align with legislative calendar, events, deadlines</li> </ul> <p>Example campaign plan:</p> <pre><code>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</code></pre>"},{"location":"v2/user-guides/campaign-manager-guide/#understanding-your-target-audience","title":"Understanding Your Target Audience","text":"<p>Who are you trying to reach?</p> <p>By government level:</p> <ul> <li>Federal campaigns: Target MPs (Members of Parliament)</li> <li>Use for: National legislation, federal regulations, federal budgets</li> <li> <p>Example: \"Urge your MP to support climate action\"</p> </li> <li> <p>Provincial campaigns: Target MPPs/MLAs (provincial legislators)</p> </li> <li>Use for: Provincial laws, education, healthcare, transportation</li> <li> <p>Example: \"Tell your MPP to fund public transit\"</p> </li> <li> <p>Municipal campaigns: Target city councillors, mayors</p> </li> <li>Use for: Local zoning, development, city services</li> <li>Example: \"Ask your councillor to protect the park\"</li> </ul> <p>By geography:</p> <ul> <li>National: All postal codes</li> <li>Provincial: Specific province(s)</li> <li>Municipal: Specific city or ward</li> <li>Custom: Specific ridings or districts</li> </ul> <p>By demographics (requires custom targeting):</p> <ul> <li>Age groups</li> <li>Interests</li> <li>Previous engagement</li> </ul> <p>Representative Lookup</p> <p>Changemaker Lite uses postal codes to look up representatives via the Represent API. Ensure your target government level has postal code coverage.</p>"},{"location":"v2/user-guides/campaign-manager-guide/#crafting-your-message","title":"Crafting Your Message","text":"<p>Your campaign email is the core of your advocacy effort. It should be:</p> <p>1. Personal</p> <ul> <li>Written in first person (\"I am writing to...\")</li> <li>Uses resident's name and contact info</li> <li>Mentions specific representative's name</li> </ul> <p>2. Clear and Specific</p> <ul> <li>States the ask in the first paragraph</li> <li>References specific legislation (bill number, name)</li> <li>Explains what you want the representative to do</li> </ul> <p>3. Compelling</p> <ul> <li>Explains why the issue matters</li> <li>Uses facts and statistics (credibly sourced)</li> <li>Includes emotional appeal (stories, impacts)</li> </ul> <p>4. Actionable</p> <ul> <li>Numbered list of specific requests</li> <li>Clear deadline (if applicable)</li> <li>Follow-up mechanism (reply, meeting, public statement)</li> </ul> <p>5. Respectful</p> <ul> <li>Professional tone</li> <li>Acknowledges representative's position</li> <li>Thanks them for considering your views</li> </ul> <p>Example effective email:</p> <pre><code>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</code></pre> <p>What makes this email effective:</p> <ul> <li>\u2705 Specific bill number (C-234)</li> <li>\u2705 Clear ask (vote YES)</li> <li>\u2705 Compelling reason (saves $14k/year)</li> <li>\u2705 Numbered action items</li> <li>\u2705 Respectful tone</li> <li>\u2705 Personal voice</li> </ul>"},{"location":"v2/user-guides/campaign-manager-guide/#creating-a-campaign","title":"Creating a Campaign","text":""},{"location":"v2/user-guides/campaign-manager-guide/#basic-campaign-setup","title":"Basic Campaign Setup","text":"<p>To create a new campaign:</p> <ol> <li>Navigate to Influence > Campaigns</li> <li>Click \"Create Campaign\"</li> <li>Fill in the form (detailed below)</li> <li>Click \"Create\"</li> </ol> <p>Your campaign starts in DRAFT status (not published).</p>"},{"location":"v2/user-guides/campaign-manager-guide/#campaign-fields","title":"Campaign Fields","text":""},{"location":"v2/user-guides/campaign-manager-guide/#title","title":"Title","text":"<p>What it is: Public-facing campaign name</p> <p>Best practices:</p> <ul> <li>Keep it short (3-7 words)</li> <li>Make it action-oriented</li> <li>Include the issue/goal</li> <li>Avoid jargon or acronyms</li> </ul> <p>Examples:</p> <ul> <li>\u2705 \"Protect Our Forests from Logging\"</li> <li>\u2705 \"Fund Public Transit Now\"</li> <li>\u2705 \"Stop Bill 123\"</li> <li>\u274c \"Environmental Advocacy Initiative 2024\" (too vague)</li> <li>\u274c \"FPTA Campaign\" (acronym unclear)</li> </ul>"},{"location":"v2/user-guides/campaign-manager-guide/#slug","title":"Slug","text":"<p>What it is: URL-friendly identifier, auto-generated from title</p> <p>Format: lowercase, hyphens for spaces, no special characters</p> <p>Examples:</p> <ul> <li>Title: \"Protect Our Forests\" \u2192 Slug: <code>protect-our-forests</code></li> <li>Title: \"Fund Public Transit\" \u2192 Slug: <code>fund-public-transit</code></li> </ul> <p>Used in URL: <code>https://yoursite.org/campaigns/protect-our-forests</code></p> <p>Slug Uniqueness</p> <p>Slugs must be unique. If you try to use a duplicate, the system will add a number (e.g., <code>protect-our-forests-2</code>).</p>"},{"location":"v2/user-guides/campaign-manager-guide/#description","title":"Description","text":"<p>What it is: Campaign overview shown on listing page and campaign detail page</p> <p>Best practices:</p> <ul> <li>2-3 sentences</li> <li>Explain the issue briefly</li> <li>Explain why it matters</li> <li>Include call to action</li> <li>HTML supported (bold, links, etc.)</li> </ul> <p>Example:</p> <pre><code><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</code></pre>"},{"location":"v2/user-guides/campaign-manager-guide/#government-level","title":"Government Level","text":"<p>What it is: Which level of government to target for representative lookup</p> <p>Options:</p> <ul> <li>FEDERAL: MPs (Members of Parliament)</li> <li>PROVINCIAL: MPPs/MLAs (provincial/territorial legislators)</li> <li>MUNICIPAL: City councillors, mayors</li> </ul> <p>You can select multiple levels if your issue spans jurisdictions.</p> <p>Example scenarios:</p> <ul> <li>Climate legislation \u2192 FEDERAL only</li> <li>Education funding \u2192 PROVINCIAL only</li> <li>Park development \u2192 MUNICIPAL only</li> <li>Transit expansion \u2192 PROVINCIAL + MUNICIPAL (both levels involved)</li> </ul>"},{"location":"v2/user-guides/campaign-manager-guide/#email-subject","title":"Email Subject","text":"<p>What it is: Subject line for emails citizens send to representatives</p> <p>Best practices:</p> <ul> <li>Keep under 60 characters (avoids truncation)</li> <li>Start with action verb (Support, Oppose, Protect, Fund)</li> <li>Include specific bill/issue name</li> <li>Use variables for personalization</li> </ul> <p>Variables available:</p> <ul> <li><code>{{USER_NAME}}</code> \u2014 Sender's name</li> <li><code>{{REP_NAME}}</code> \u2014 Representative's name</li> <li><code>{{REP_TITLE}}</code> \u2014 Representative's title (MP, MPP, Councillor)</li> </ul> <p>Examples:</p> <ul> <li>\u2705 \"Please support Bill C-234 for family farms\"</li> <li>\u2705 \"Vote YES on climate action legislation\"</li> <li>\u2705 \"Oppose the proposed park development\"</li> <li>\u274c \"Your constituent has an important message for you\" (too vague)</li> <li>\u274c \"I am writing to express my concern about the environmental degradation...\" (too long)</li> </ul>"},{"location":"v2/user-guides/campaign-manager-guide/#email-body","title":"Email Body","text":"<p>What it is: The email message template citizens send</p> <p>Structure:</p> <pre><code>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</code></pre> <p>Variables available:</p> <ul> <li><code>{{USER_NAME}}</code> \u2014 Citizen's full name</li> <li><code>{{USER_EMAIL}}</code> \u2014 Citizen's email</li> <li><code>{{USER_PHONE}}</code> \u2014 Citizen's phone (if collected)</li> <li><code>{{REP_NAME}}</code> \u2014 Representative's name</li> <li><code>{{REP_EMAIL}}</code> \u2014 Representative's email</li> <li><code>{{REP_TITLE}}</code> \u2014 Representative's title (MP, MPP, Councillor)</li> <li><code>{{USER_MESSAGE}}</code> \u2014 Citizen's custom message (optional field on form)</li> </ul> <p>Tips:</p> <ul> <li>Use HTML editor for formatting (bold, lists, links)</li> <li>Include <code>{{USER_MESSAGE}}</code> at the end so citizens can add personal stories</li> <li>Keep base template to 200-400 words (short enough to read, detailed enough to be persuasive)</li> <li>Preview before publishing (send test email to yourself)</li> </ul>"},{"location":"v2/user-guides/campaign-manager-guide/#cover-photo-optional","title":"Cover Photo (Optional)","text":"<p>What it is: Image shown on campaign listing and detail pages</p> <p>Best practices:</p> <ul> <li>Use high-quality image (at least 1200x630 px)</li> <li>Relevant to issue (photo of forest for forestry campaign, etc.)</li> <li>Not too busy (text overlays should be readable)</li> <li>Use your own photos or Creative Commons licensed images</li> </ul> <p>Upload: Provide URL to image (must host image externally or use media library)</p>"},{"location":"v2/user-guides/campaign-manager-guide/#configuring-feature-flags","title":"Configuring Feature Flags","text":"<p>Feature flags control campaign functionality. Here's a detailed guide on when to use each:</p>"},{"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":"<p>What it does: Makes campaign visible on public listing page</p> <p>When to enable:</p> <ul> <li>\u2705 Campaign is ready to launch</li> <li>\u2705 Email template is proofread and tested</li> <li>\u2705 Representative lookup is working</li> </ul> <p>When to disable:</p> <ul> <li>\u274c Campaign is still being built (draft)</li> <li>\u274c Campaign has ended (or use <code>disable_after_date</code>)</li> <li>\u274c Need to make changes (unpublish temporarily)</li> </ul> <p>Unpublishing</p> <p>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.</p>"},{"location":"v2/user-guides/campaign-manager-guide/#2-featured","title":"2. Featured","text":"<p>What it does: Displays campaign prominently at top of listing page</p> <p>When to enable:</p> <ul> <li>\u2705 Highest priority campaign</li> <li>\u2705 Time-sensitive (vote happening soon)</li> <li>\u2705 Major organizational focus</li> </ul> <p>Best practices:</p> <ul> <li>Limit to 2-3 featured campaigns</li> <li>Rotate featured status based on priority</li> <li>Feature new campaigns for first week to boost initial signups</li> </ul>"},{"location":"v2/user-guides/campaign-manager-guide/#3-has-response-wall","title":"3. Has Response Wall","text":"<p>What it does: Allows citizens to share personal stories publicly after emailing</p> <p>When to enable:</p> <ul> <li>\u2705 You want to showcase public support</li> <li>\u2705 You have capacity for moderation (unless auto-approve)</li> <li>\u2705 Issue benefits from personal stories</li> </ul> <p>When to disable:</p> <ul> <li>\u274c Privacy concerns (sensitive issues)</li> <li>\u274c No moderation capacity</li> <li>\u274c Campaign is purely about email volume (not stories)</li> </ul> <p>Moderation required: Unless <code>auto_approve_responses</code> is enabled, all responses must be manually approved.</p>"},{"location":"v2/user-guides/campaign-manager-guide/#advanced-feature-flags","title":"Advanced Feature Flags","text":""},{"location":"v2/user-guides/campaign-manager-guide/#4-collect-phone-numbers","title":"4. Collect Phone Numbers","text":"<p>What it does: Adds optional phone number field to campaign form</p> <p>When to enable:</p> <ul> <li>\u2705 Running a blended email + phone campaign</li> <li>\u2705 Want to follow up with phone calls</li> <li>\u2705 Building contact list for future outreach</li> </ul> <p>When to disable:</p> <ul> <li>\u274c Privacy concerns (reduces conversion)</li> <li>\u274c No plan to use phone numbers</li> </ul> <p>Data usage: Phone numbers are stored in campaign responses and visible to admins.</p>"},{"location":"v2/user-guides/campaign-manager-guide/#5-track-calls","title":"5. Track Calls","text":"<p>What it does: Adds \"I called my representative\" button and tracks call attempts</p> <p>When to enable:</p> <ul> <li>\u2705 Running a call-in campaign</li> <li>\u2705 Encouraging both emails and calls</li> <li>\u2705 Want to track total contact attempts (emails + calls)</li> </ul> <p>How it works:</p> <ul> <li>After sending email, user sees \"I also called\" button</li> <li>Clicking increments call counter</li> <li>Calls tracked separately from emails</li> </ul>"},{"location":"v2/user-guides/campaign-manager-guide/#6-require-verification","title":"6. Require Verification","text":"<p>What it does: Sends verification email before recording email send</p> <p>When to enable:</p> <ul> <li>\u2705 Public campaigns (prevents spam)</li> <li>\u2705 High-profile campaigns (media attention)</li> <li>\u2705 Need accurate email counts</li> </ul> <p>When to disable:</p> <ul> <li>\u274c Internal campaigns (trusted users only)</li> <li>\u274c Want to reduce friction (lowers completion rate by ~20%)</li> </ul> <p>How it works:</p> <ol> <li>User fills out form and clicks \"Send\"</li> <li>System sends verification email</li> <li>User clicks link in email</li> <li>Email to representative is sent</li> <li>Response is recorded</li> </ol> <p>Recommended</p> <p>Enable verification for all public campaigns to prevent spam and ensure data quality.</p>"},{"location":"v2/user-guides/campaign-manager-guide/#7-auto-approve-responses","title":"7. Auto Approve Responses","text":"<p>What it does: Response wall submissions appear immediately without moderation</p> <p>When to enable:</p> <ul> <li>\u2705 Trusted audience (members-only campaign)</li> <li>\u2705 Low-risk issue (unlikely to attract trolls)</li> <li>\u2705 No moderation capacity</li> </ul> <p>When to disable:</p> <ul> <li>\u274c Public campaigns (risk of spam/abuse)</li> <li>\u274c Controversial issues (may attract hostile responses)</li> <li>\u274c Need quality control</li> </ul> <p>Moderation Recommended</p> <p>Most public campaigns should NOT auto-approve. Manual moderation ensures quality and prevents abuse.</p>"},{"location":"v2/user-guides/campaign-manager-guide/#8-allow-anonymous","title":"8. Allow Anonymous","text":"<p>What it does: Citizens can send emails without creating an account</p> <p>When to enable:</p> <ul> <li>\u2705 Want to maximize participation</li> <li>\u2705 Privacy-sensitive issue</li> <li>\u2705 One-time campaign (no need to track individuals)</li> </ul> <p>When to disable:</p> <ul> <li>\u274c Building supporter list (want account creation)</li> <li>\u274c Need to prevent duplicate submissions</li> <li>\u274c Want to track individual engagement over time</li> </ul> <p>Trade-offs:</p> <ul> <li>\u2705 Higher conversion (less friction)</li> <li>\u274c Cannot prevent duplicate emails from same person</li> <li>\u274c No account to re-engage supporters later</li> </ul>"},{"location":"v2/user-guides/campaign-manager-guide/#9-custom-recipients","title":"9. Custom Recipients","text":"<p>What it does: Override representative lookup and send to specific email addresses</p> <p>When to enable:</p> <ul> <li>\u2705 Targeting non-government decision-makers (corporate executives, university presidents)</li> <li>\u2705 Representative lookup doesn't cover your target (small municipalities)</li> <li>\u2705 Want to target specific individuals regardless of postal code</li> </ul> <p>How to use:</p> <ol> <li>Enable flag</li> <li>Enter comma-separated email addresses in <code>custom_recipient_emails</code> field</li> <li>Optionally enter custom recipient names in <code>custom_recipient_names</code> field</li> </ol> <p>Example:</p> <pre><code>custom_recipient_emails: ceo@corporation.com,president@university.edu\ncustom_recipient_names: CEO John Smith,University President Jane Doe\n</code></pre> <p>All emails will go to these addresses instead of postal code lookup.</p>"},{"location":"v2/user-guides/campaign-manager-guide/#10-show-progress-bar","title":"10. Show Progress Bar","text":"<p>What it does: Displays progress bar showing emails sent toward goal</p> <p>When to enable:</p> <ul> <li>\u2705 Have a specific email goal</li> <li>\u2705 Want to motivate participation (\"We're 75% to our goal!\")</li> <li>\u2705 Creating urgency</li> </ul> <p>How to use:</p> <ol> <li>Enable flag</li> <li>Set <code>email_goal</code> field (e.g., 1000)</li> <li>Progress bar appears on campaign page showing current count / goal</li> </ol> <p>Example display:</p> <pre><code>[=========> ] 734 / 1,000 emails sent (73%)\n</code></pre> <p>Set Realistic Goals</p> <p>Research similar campaigns to set achievable goals. Falling short publicly can be demotivating.</p>"},{"location":"v2/user-guides/campaign-manager-guide/#11-disable-after-date","title":"11. Disable After Date","text":"<p>What it does: Automatically unpublish campaign after specified date</p> <p>When to enable:</p> <ul> <li>\u2705 Time-sensitive campaign (vote deadline)</li> <li>\u2705 Want campaign to auto-close</li> <li>\u2705 Don't want to manually unpublish</li> </ul> <p>How to use:</p> <ol> <li>Enable flag</li> <li>Set <code>disable_date</code> field (date picker)</li> <li>Campaign automatically unpublishes at midnight on that date</li> </ol> <p>Example:</p> <p>Legislative vote is March 15. Set <code>disable_date</code> to March 15, 2024. Campaign automatically closes that day.</p>"},{"location":"v2/user-guides/campaign-manager-guide/#12-enable-comments","title":"12. Enable Comments","text":"<p>What it does: Allows comments on response wall entries (discussion threads)</p> <p>When to enable:</p> <ul> <li>\u2705 Want to encourage discussion</li> <li>\u2705 Have moderation capacity for comments</li> <li>\u2705 Building community</li> </ul> <p>When to disable:</p> <ul> <li>\u274c No comment moderation capacity</li> <li>\u274c Risk of hostile/off-topic discussion</li> <li>\u274c Prefer clean, simple response wall</li> </ul> <p>Experimental Feature</p> <p>Comments require additional moderation. Consider carefully before enabling.</p>"},{"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":"<p>Do:</p> <ul> <li>\u2705 Keep under 60 characters</li> <li>\u2705 Start with action verb (Vote, Support, Oppose, Protect)</li> <li>\u2705 Include bill number or issue name</li> <li>\u2705 Create urgency (if appropriate)</li> </ul> <p>Don't:</p> <ul> <li>\u274c Use ALL CAPS (looks like spam)</li> <li>\u274c Use excessive punctuation (!!!)</li> <li>\u274c Make false claims or exaggerations</li> <li>\u274c Use clickbait (\"You won't believe...\")</li> </ul> <p>Examples:</p> 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":"<p>Recommended structure:</p> <pre><code>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</code></pre>"},{"location":"v2/user-guides/campaign-manager-guide/#using-variables-effectively","title":"Using Variables Effectively","text":"<p>Available variables:</p> Variable Description Example Output <code>{{USER_NAME}}</code> Sender's full name \"John Smith\" <code>{{USER_EMAIL}}</code> Sender's email \"john@example.com\" <code>{{USER_PHONE}}</code> Sender's phone \"555-1234\" <code>{{REP_NAME}}</code> Representative's name \"Hon. Jane Doe\" <code>{{REP_EMAIL}}</code> Representative's email \"jane.doe@parl.gc.ca\" <code>{{REP_TITLE}}</code> Representative's title \"Member of Parliament\" <code>{{USER_MESSAGE}}</code> Custom message (whatever user typed) <p>Best practices:</p> <ol> <li>Always use {{REP_NAME}} in greeting \u2014 Personalizes email</li> <li>Include {{USER_NAME}} in signature \u2014 Shows it's from a real person</li> <li>Add {{USER_MESSAGE}} at end \u2014 Allows personalization</li> <li>Use {{REP_TITLE}} for variety \u2014 Avoid repeating \"Member of Parliament\"</li> </ol> <p>Example usage:</p> <pre><code>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</code></pre>"},{"location":"v2/user-guides/campaign-manager-guide/#html-formatting-tips","title":"HTML Formatting Tips","text":"<p>The email editor supports HTML. Use formatting to improve readability:</p> <p>Headings:</p> <pre><code><h3>Why This Matters</h3>\n</code></pre> <p>Bold text:</p> <pre><code><strong>Vote YES on Bill C-123</strong>\n</code></pre> <p>Lists:</p> <pre><code><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</code></pre> <p>Links:</p> <pre><code><a href=\"https://example.com/research\">Read the full study here</a>\n</code></pre> <p>Line breaks:</p> <pre><code><p>First paragraph.</p>\n<p>Second paragraph.</p>\n</code></pre> <p>Email Client Compatibility</p> <p>Avoid complex CSS or JavaScript. Stick to basic HTML tags (p, strong, em, ul, ol, a). Many email clients strip advanced formatting.</p>"},{"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":"<p>Before publishing, verify:</p> <ul> <li> Email template proofread \u2014 No typos, grammar errors</li> <li> Variables working \u2014 Test with your own postal code</li> <li> Representative lookup functional \u2014 Test multiple postal codes</li> <li> Feature flags configured \u2014 Review all 12 flags</li> <li> Cover photo uploaded \u2014 Image displays correctly</li> <li> Response wall ready \u2014 Moderation plan in place (if enabled)</li> <li> Email goal set \u2014 If using progress bar</li> <li> Disable date set \u2014 If time-sensitive campaign</li> <li> Test email sent \u2014 Send to yourself, verify formatting</li> </ul> <p>To send a test email:</p> <ol> <li>Edit the campaign</li> <li>Scroll to email section</li> <li>Click \"Send Test Email\"</li> <li>Enter your email address</li> <li>Check your inbox</li> </ol> <p>The test email uses sample data for variables.</p>"},{"location":"v2/user-guides/campaign-manager-guide/#publishing","title":"Publishing","text":"<p>To publish:</p> <ol> <li>Edit the campaign</li> <li>Toggle \"Published\" flag to ON</li> <li>Click \"Save\"</li> </ol> <p>The campaign is now live at <code>/campaigns/[slug]</code>.</p>"},{"location":"v2/user-guides/campaign-manager-guide/#promoting-your-campaign","title":"Promoting Your Campaign","text":"<p>Promotion channels:</p> <ol> <li>Direct link: Share <code>https://yoursite.org/campaigns/protect-our-forests</code></li> <li>Email newsletter: Include in your regular newsletter</li> <li>Social media: Post on Facebook, Twitter, Instagram with link</li> <li>Website: Add to your main website's homepage or action page</li> <li>Partner organizations: Ask allies to share</li> <li>Earned media: Pitch to journalists, bloggers</li> </ol> <p>Sample social media post:</p> <pre><code>\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</code></pre> <p>Sample email newsletter:</p> <pre><code>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</code></pre>"},{"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":"<p>To view email stats:</p> <ol> <li>Navigate to Influence > Campaigns</li> <li>Click \"Emails\" button for your campaign</li> </ol> <p>The drawer shows:</p> <p>Overall statistics:</p> <ul> <li>Total emails sent: All emails successfully delivered</li> <li>Emails waiting: Queued but not yet sent</li> <li>Failed emails: Delivery failures</li> <li>Success rate: Sent / (Sent + Failed)</li> </ul> <p>Email list table:</p> <ul> <li>Sender name and email</li> <li>Recipient representative</li> <li>Status (PENDING, SENT, FAILED)</li> <li>Sent timestamp</li> <li>Error message (if failed)</li> </ul> <p>Screenshot placeholder: Campaign Emails drawer showing statistics and email list</p>"},{"location":"v2/user-guides/campaign-manager-guide/#understanding-email-status","title":"Understanding Email Status","text":"<p>PENDING:</p> <ul> <li>Email is queued for sending</li> <li>Usually sent within minutes</li> <li>If stuck for > 1 hour, check queue (see below)</li> </ul> <p>SENT:</p> <ul> <li>Email successfully delivered to representative</li> <li>Does NOT guarantee representative read it (that's on them)</li> </ul> <p>FAILED:</p> <ul> <li>Email delivery failed</li> <li>Common reasons:</li> <li>Invalid recipient email (representative email wrong in database)</li> <li>SMTP error (email server rejected)</li> <li>Network timeout</li> </ul> <p>Retry failed emails:</p> <ol> <li>Click \"Retry Failed\" button</li> <li>System re-queues failed emails</li> <li>Check again in 10 minutes</li> </ol> <p>Representative Emails</p> <p>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.</p>"},{"location":"v2/user-guides/campaign-manager-guide/#response-wall-statistics","title":"Response Wall Statistics","text":"<p>To view response wall stats:</p> <ol> <li>Navigate to Influence > Responses</li> <li>Filter by your campaign</li> </ol> <p>Metrics:</p> <ul> <li>Total responses: All submissions (approved + pending + rejected)</li> <li>Approved: Visible on public response wall</li> <li>Pending: Awaiting moderation</li> <li>Rejected: Hidden from public</li> <li>Upvotes: Total upvotes across all responses</li> </ul> <p>Response rate:</p> <pre><code>Response rate = Responses / Emails sent\n</code></pre> <p>Typical response rates:</p> <ul> <li>5-10% \u2014 Good response rate</li> <li>10-20% \u2014 Excellent response rate</li> <li>< 5% \u2014 Low engagement (consider improving response wall CTA)</li> </ul>"},{"location":"v2/user-guides/campaign-manager-guide/#email-queue-health","title":"Email Queue Health","text":"<p>To monitor the queue:</p> <ol> <li>Navigate to Influence > Email Queue</li> </ol> <p>Key metrics:</p> <ul> <li>Waiting: Emails in queue, not yet processing</li> <li>Normal: < 50</li> <li>Concerning: 50-200</li> <li> <p>Critical: > 200 (likely queue backup)</p> </li> <li> <p>Active: Emails currently being sent</p> </li> <li> <p>Normal: 1-5 (concurrent workers)</p> </li> <li> <p>Completed (last 24 hours): Successfully sent</p> </li> <li> <p>Failed: Delivery failures</p> </li> <li>Normal: < 5% of sent</li> <li>Concerning: 5-20%</li> <li>Critical: > 20% (SMTP issue)</li> </ul> <p>Queue controls:</p> <ul> <li>Pause Queue: Emergency stop (only use during SMTP issues)</li> <li>Resume Queue: Restart after pause</li> <li>Retry Failed: Re-queue all failed emails</li> <li>Clean Completed: Remove old completed jobs (frees memory)</li> </ul> <p>Queue Pausing</p> <p>Only pause the queue if SMTP is broken or you're changing email configuration. Citizens expect immediate sends.</p>"},{"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":"<p>To moderate responses:</p> <ol> <li>Navigate to Influence > Responses</li> <li>Filter to Status: PENDING</li> <li>Review each response</li> <li>Approve or reject</li> </ol> <p>Moderation decisions:</p> <p>Approve if:</p> <ul> <li>\u2705 Authentic personal story</li> <li>\u2705 Relates to campaign issue</li> <li>\u2705 Respectful language</li> <li>\u2705 Adds value to public conversation</li> </ul> <p>Reject if:</p> <ul> <li>\u274c Spam or bot submission</li> <li>\u274c Profanity, hate speech, or harassment</li> <li>\u274c Off-topic or unrelated to campaign</li> <li>\u274c Contains personal information about others (privacy violation)</li> <li>\u274c Duplicate submission (approve one, reject others)</li> </ul> <p>Delete if:</p> <ul> <li>Illegal content</li> <li>Severe harassment or threats</li> <li>Privacy violation (doxxing)</li> </ul>"},{"location":"v2/user-guides/campaign-manager-guide/#reviewing-a-response","title":"Reviewing a Response","text":"<p>To review in detail:</p> <ol> <li>Click \"View\" in Actions column</li> <li>Read full response text</li> <li>Check submitter info (name, email, timestamp)</li> <li>Decide: Approve, Reject, or Delete</li> </ol> <p>Response detail shows:</p> <ul> <li>Full text of response</li> <li>Submitter name and email (not public)</li> <li>Submission timestamp</li> <li>Associated campaign</li> <li>Current status</li> <li>Upvote count (if already approved)</li> </ul> <p>Actions:</p> <ul> <li>Approve: Make public (appears on response wall)</li> <li>Reject: Hide from public (not deleted, can reverse later)</li> <li>Delete: Permanently remove (cannot undo)</li> <li>Edit: Fix typos or formatting (use sparingly)</li> </ul> <p>Editing Responses</p> <p>Only edit responses to fix obvious typos or remove sensitive info (phone numbers, addresses). Don't change meaning.</p>"},{"location":"v2/user-guides/campaign-manager-guide/#moderation-best-practices","title":"Moderation Best Practices","text":"<p>Speed matters:</p> <ul> <li>Review pending responses daily (at minimum)</li> <li>For time-sensitive campaigns, review 2-3x per day</li> <li>Long moderation delays reduce participation (people won't share if they never see results)</li> </ul> <p>Consistency:</p> <ul> <li>Use same criteria for all responses</li> <li>Document your moderation guidelines</li> <li>If multiple moderators, ensure they're aligned</li> </ul> <p>Encourage quality:</p> <ul> <li>Spotlight particularly good responses (if feature available)</li> <li>Share excellent responses on social media</li> <li>Thank respondents for sharing their stories</li> </ul> <p>Handle edge cases:</p> <ul> <li>Political/controversial: Allow diverse viewpoints as long as respectful</li> <li>Emotional language: Allow passion, reject profanity</li> <li>Minor inaccuracies: Approve (you're not fact-checking everything)</li> <li>Self-promotion: Reject if primary purpose is advertising</li> </ul>"},{"location":"v2/user-guides/campaign-manager-guide/#responding-to-moderation-issues","title":"Responding to Moderation Issues","text":"<p>If you accidentally reject a good response:</p> <ol> <li>Find the response in table</li> <li>Change status from REJECTED to APPROVED</li> <li>Response immediately appears on response wall</li> </ol> <p>If inappropriate content slips through:</p> <ol> <li>Find the response</li> <li>Change status from APPROVED to REJECTED (or delete)</li> <li>Response immediately removed from public view</li> </ol> <p>If user complains about rejection:</p> <ol> <li>Review the response again</li> <li>If rejection was correct, explain your moderation policy</li> <li>If rejection was incorrect, approve and apologize</li> <li>Consider revising moderation guidelines to prevent future issues</li> </ol>"},{"location":"v2/user-guides/campaign-manager-guide/#optimization-strategies","title":"Optimization Strategies","text":""},{"location":"v2/user-guides/campaign-manager-guide/#improving-email-conversion-rates","title":"Improving Email Conversion Rates","text":"<p>Conversion rate = Emails sent / Page visitors</p> <p>Typical conversion rates:</p> <ul> <li>5-10% \u2014 Average for advocacy campaigns</li> <li>10-20% \u2014 Good (well-designed campaign)</li> <li>20%+ \u2014 Excellent (highly motivated audience)</li> </ul> <p>Tactics to improve conversion:</p>"},{"location":"v2/user-guides/campaign-manager-guide/#1-simplify-the-form","title":"1. Simplify the Form","text":"<ul> <li>Remove optional fields (phone number, custom message)</li> <li>Use postal code autofill</li> <li>Pre-fill email for logged-in users</li> </ul>"},{"location":"v2/user-guides/campaign-manager-guide/#2-reduce-friction","title":"2. Reduce Friction","text":"<ul> <li>Disable email verification (if spam isn't an issue)</li> <li>Allow anonymous submissions (no account required)</li> <li>Use clear, simple language</li> </ul>"},{"location":"v2/user-guides/campaign-manager-guide/#3-strengthen-the-call-to-action","title":"3. Strengthen the Call to Action","text":"<ul> <li>Use large, prominent \"Send Email\" button</li> <li>Add urgency (\"Vote is tomorrow \u2014 act now!\")</li> <li>Show social proof (\"Join 1,234 others who've sent emails\")</li> </ul>"},{"location":"v2/user-guides/campaign-manager-guide/#4-improve-email-template","title":"4. Improve Email Template","text":"<ul> <li>Make it personal (use variables)</li> <li>Keep it short (200-300 words)</li> <li>Include specific ask (bill number, action)</li> <li>Allow personalization ({{USER_MESSAGE}})</li> </ul>"},{"location":"v2/user-guides/campaign-manager-guide/#5-add-trust-signals","title":"5. Add Trust Signals","text":"<ul> <li>Show organization logo</li> <li>Display privacy policy link</li> <li>Explain what happens after they send (\"Your representative will receive this email within minutes\")</li> </ul>"},{"location":"v2/user-guides/campaign-manager-guide/#ab-testing","title":"A/B Testing","text":"<p>Test different versions of your campaign to find what works best.</p> <p>Elements to test:</p> <ol> <li>Email subject line</li> <li>Action-oriented vs question</li> <li>Include bill number vs generic</li> <li> <p>Urgent vs neutral tone</p> </li> <li> <p>Call to action</p> </li> <li>\"Send Email\" vs \"Take Action\" vs \"Email Your MP\"</li> <li>Button color (blue vs red vs green)</li> <li> <p>Button size</p> </li> <li> <p>Campaign description</p> </li> <li>Short (1 sentence) vs detailed (3 paragraphs)</li> <li>Emotional appeal vs factual</li> <li> <p>Include statistics vs stories</p> </li> <li> <p>Feature flags</p> </li> <li>Email verification ON vs OFF</li> <li>Response wall ON vs OFF</li> <li>Progress bar ON vs OFF</li> </ol> <p>How to A/B test:</p> <ol> <li>Create two versions of the campaign (duplicate the campaign)</li> <li>Change ONE variable (e.g., subject line)</li> <li>Send 50% of traffic to each version (promote both equally)</li> <li>After 100+ emails sent per version, compare conversion rates</li> <li>Keep the winner, discard the loser</li> </ol> <p>Sample A/B test:</p> <pre><code>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</code></pre>"},{"location":"v2/user-guides/campaign-manager-guide/#encouraging-response-wall-participation","title":"Encouraging Response Wall Participation","text":"<p>Response wall benefits:</p> <ul> <li>Shows public support visibly</li> <li>Creates peer pressure (\"If they can share, so can I\")</li> <li>Provides human stories for media and decision-makers</li> </ul> <p>Tactics to increase responses:</p>"},{"location":"v2/user-guides/campaign-manager-guide/#1-highlight-the-response-wall","title":"1. Highlight the Response Wall","text":"<ul> <li>Add text after email send: \"Share your story with the community\"</li> <li>Show recent responses on campaign page</li> <li>Feature excellent responses on social media</li> </ul>"},{"location":"v2/user-guides/campaign-manager-guide/#2-reduce-friction_1","title":"2. Reduce Friction","text":"<ul> <li>Auto-approve responses (if audience is trusted)</li> <li>Pre-fill response form with email content</li> <li>Allow anonymous responses</li> </ul>"},{"location":"v2/user-guides/campaign-manager-guide/#3-provide-examples","title":"3. Provide Examples","text":"<ul> <li>Seed the response wall with 3-5 initial responses (from staff/volunteers)</li> <li>Show variety of response types (personal story, factual argument, emotional appeal)</li> </ul>"},{"location":"v2/user-guides/campaign-manager-guide/#4-incentivize-participation","title":"4. Incentivize Participation","text":"<ul> <li>Run a contest (best response wins a prize)</li> <li>Feature responses in newsletter</li> <li>Invite top responders to speak at event</li> </ul>"},{"location":"v2/user-guides/campaign-manager-guide/#5-moderate-quickly","title":"5. Moderate Quickly","text":"<ul> <li>Approve responses within hours (not days)</li> <li>People won't share if they never see results</li> </ul>"},{"location":"v2/user-guides/campaign-manager-guide/#boosting-upvotes","title":"Boosting Upvotes","text":"<p>Upvotes signal which responses resonate most with your community.</p> <p>Tactics:</p> <ol> <li>Make upvoting easy: One-click, no login required</li> <li>Show upvote counts: Create competition</li> <li>Promote top responses: Share high-upvote responses on social</li> <li>Create urgency: \"Most upvoted response will be featured in our newsletter\"</li> </ol>"},{"location":"v2/user-guides/campaign-manager-guide/#reporting-and-analytics","title":"Reporting and Analytics","text":""},{"location":"v2/user-guides/campaign-manager-guide/#campaign-performance-report","title":"Campaign Performance Report","text":"<p>Key metrics to track:</p> 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":"<p>To export campaign data:</p> <ol> <li>Navigate to Influence > Campaigns</li> <li>Click \"Emails\" for your campaign</li> <li>Click \"Export CSV\"</li> </ol> <p>CSV includes:</p> <ul> <li>Sender name and email</li> <li>Recipient representative</li> <li>Email sent timestamp</li> <li>Status (SENT, FAILED, PENDING)</li> <li>Error message (if failed)</li> </ul> <p>Use cases:</p> <ul> <li>Analyze email volume by date (chart over time)</li> <li>Identify which representatives received most emails (top targets)</li> <li>Follow up with failed sends</li> <li>Import into CRM or email tool</li> </ul> <p>Response wall export:</p> <ol> <li>Navigate to Influence > Responses</li> <li>Filter by campaign</li> <li>Click \"Export CSV\"</li> </ol> <p>CSV includes:</p> <ul> <li>Respondent name and email</li> <li>Response text</li> <li>Submission date</li> <li>Status (APPROVED, PENDING, REJECTED)</li> <li>Upvote count</li> </ul> <p>Use cases:</p> <ul> <li>Analyze themes in responses (word cloud, sentiment analysis)</li> <li>Share stories with media or decision-makers</li> <li>Feature responses in reports or presentations</li> </ul>"},{"location":"v2/user-guides/campaign-manager-guide/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/user-guides/campaign-manager-guide/#low-email-conversion-rate","title":"Low Email Conversion Rate","text":"<p>Symptoms: Few people sending emails despite high traffic</p> <p>Diagnostic questions:</p> <ol> <li>Is representative lookup working?</li> <li>Test with multiple postal codes</li> <li> <p>Check representative cache (Influence > Representatives)</p> </li> <li> <p>Is the form too complex?</p> </li> <li>Remove optional fields</li> <li>Simplify email template</li> <li> <p>Disable verification</p> </li> <li> <p>Is the call to action clear?</p> </li> <li>Review campaign description</li> <li>Check button text and prominence</li> <li> <p>Add urgency or social proof</p> </li> <li> <p>Is trust an issue?</p> </li> <li>Add organization branding</li> <li>Display privacy policy</li> <li>Explain what happens after they send</li> </ol> <p>Solutions:</p> <ul> <li>A/B test simpler version</li> <li>Add trust signals (logo, privacy link)</li> <li>Reduce form fields</li> <li>Strengthen CTA</li> </ul>"},{"location":"v2/user-guides/campaign-manager-guide/#low-response-wall-participation","title":"Low Response Wall Participation","text":"<p>Symptoms: Emails being sent but few response wall submissions</p> <p>Possible causes:</p> <ol> <li>Response wall not prominent</li> <li>Add section on campaign page highlighting response wall</li> <li> <p>Show recent responses below email form</p> </li> <li> <p>Friction too high</p> </li> <li>Require verification \u2192 people abandon</li> <li> <p>Long approval delay \u2192 people think it didn't work</p> </li> <li> <p>No examples/social proof</p> </li> <li>Empty response wall \u2192 people don't know what to share</li> <li>Seed with initial responses</li> </ol> <p>Solutions:</p> <ul> <li>Auto-approve responses (if trusted audience)</li> <li>Add examples/prompts (\"Share why this issue matters to you\")</li> <li>Feature excellent responses on social media (encourages others)</li> </ul>"},{"location":"v2/user-guides/campaign-manager-guide/#emails-stuck-in-queue","title":"Emails Stuck in Queue","text":"<p>Symptoms: Emails remain in PENDING status for > 1 hour</p> <p>Diagnostic steps:</p> <ol> <li>Check queue status: Influence > Email Queue</li> <li>Check SMTP configuration: Settings > Email Configuration</li> <li>Test email send: Settings > Send Test Email</li> </ol> <p>Common causes:</p> <ol> <li>Queue worker not running</li> <li>Contact system administrator</li> <li> <p>Restart api service</p> </li> <li> <p>SMTP credentials wrong</p> </li> <li>Verify username/password in Settings</li> <li> <p>Send test email to verify</p> </li> <li> <p>SMTP server rejecting</p> </li> <li>Check spam/rate limits on SMTP server</li> <li> <p>Contact email service provider</p> </li> <li> <p>Network issue</p> </li> <li>Check API server connectivity</li> <li>Try different SMTP provider</li> </ol> <p>Emergency solution:</p> <ul> <li>If queue is badly backed up, pause queue</li> <li>Fix SMTP issue</li> <li>Resume queue</li> <li>Retry failed</li> </ul>"},{"location":"v2/user-guides/campaign-manager-guide/#high-email-failure-rate","title":"High Email Failure Rate","text":"<p>Symptoms: Many emails with FAILED status</p> <p>Check error messages:</p> <ol> <li>\"Invalid recipient email\"</li> <li>Representative email is wrong in database</li> <li>Contact Represent API maintainers</li> <li> <p>Use custom recipients as workaround</p> </li> <li> <p>\"SMTP authentication failed\"</p> </li> <li>Wrong SMTP username/password</li> <li> <p>Update in Settings > Email Configuration</p> </li> <li> <p>\"Connection timeout\"</p> </li> <li>Network issue between API server and SMTP</li> <li> <p>Contact system administrator</p> </li> <li> <p>\"Mailbox full\"</p> </li> <li>Representative's email inbox is full</li> <li> <p>Nothing you can do (contact representative's office)</p> </li> <li> <p>\"Spam filter rejected\"</p> </li> <li>Email looks like spam</li> <li>Revise email template (less spammy language)</li> <li>Contact SMTP provider about reputation</li> </ol> <p>Solutions:</p> <ul> <li>Fix SMTP configuration</li> <li>Update representative emails</li> <li>Retry failed emails after fixing</li> </ul>"},{"location":"v2/user-guides/campaign-manager-guide/#related-documentation","title":"Related Documentation","text":"<ul> <li>Admin Guide: Full administrator guide (includes campaign management)</li> <li>Influence Module: Technical documentation on campaigns and email system</li> <li>Email Queue: BullMQ queue technical details</li> <li>Response Wall: Response moderation and upvoting</li> <li>API Reference: Influence API endpoints</li> </ul> <p>Last updated: February 2026 (V2 complete)</p>"},{"location":"v2/user-guides/content-editor-guide/","title":"Content Editor Guide","text":""},{"location":"v2/user-guides/content-editor-guide/#overview","title":"Overview","text":"<p>As a Content Editor, you're responsible for creating and managing public-facing content in Changemaker Lite, including:</p> <ul> <li>Landing pages: Custom web pages using the visual editor</li> <li>Email templates: System email templates (welcome, password reset, shift reminders)</li> <li>Media library: Video uploads and organization (if enabled)</li> </ul> <p>This guide will help you create professional, engaging content that drives participation in your campaigns and volunteer activities.</p>"},{"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":"<p>Content editing features are available to:</p> <ul> <li>SUPER_ADMIN: Full access to all content features</li> <li>INFLUENCE_ADMIN: Email templates (for campaign-related emails)</li> <li>MAP_ADMIN: Email templates (for shift-related emails)</li> </ul> <p>Landing pages and media library are typically managed by SUPER_ADMIN only.</p>"},{"location":"v2/user-guides/content-editor-guide/#content-areas","title":"Content Areas","text":"<p>1. Landing Pages (<code>/app/pages</code>)</p> <ul> <li>Custom public pages at <code>/p/[slug]</code></li> <li>Visual editor (GrapesJS) or code editor</li> <li>Use for: Campaign pages, donation pages, event pages</li> </ul> <p>2. Email Templates (<code>/app/email-templates</code>)</p> <ul> <li>System email templates</li> <li>HTML + plain text versions</li> <li>Use for: Welcome emails, shift reminders, password resets</li> </ul> <p>3. Media Library (<code>/app/media/library</code>, if enabled)</p> <ul> <li>Video uploads and organization</li> <li>Shareable public gallery</li> <li>Use for: Testimonials, events, educational content</li> </ul>"},{"location":"v2/user-guides/content-editor-guide/#creating-landing-pages","title":"Creating Landing Pages","text":""},{"location":"v2/user-guides/content-editor-guide/#landing-page-overview","title":"Landing Page Overview","text":"<p>Landing pages are custom web pages published at <code>/p/[slug]</code>. Use them for:</p> <ul> <li>Campaign-specific pages: Dedicated page for a major campaign</li> <li>Event registration: Custom signup forms for events</li> <li>Donation pages: Integrated donation forms</li> <li>About pages: \"About Us\", \"Our Team\", \"Our Mission\"</li> <li>Volunteer recruitment: Custom volunteer signup pages</li> </ul>"},{"location":"v2/user-guides/content-editor-guide/#creating-a-new-page","title":"Creating a New Page","text":"<p>To create a landing page:</p> <ol> <li>Navigate to Content > Landing Pages</li> <li>Click \"Create Page\"</li> <li>Fill in page details:</li> <li>Title: Page title (shown in browser tab, used for SEO)</li> <li>Slug: URL identifier (e.g., <code>about-us</code> \u2192 <code>/p/about-us</code>)</li> <li>Description: Meta description for SEO (160 characters max)</li> <li>Status: DRAFT or PUBLISHED</li> <li>Click \"Create\"</li> <li>Click \"Edit\" to open the page editor</li> </ol> <p>Screenshot placeholder: Create Page modal showing title, slug, description, and status fields</p>"},{"location":"v2/user-guides/content-editor-guide/#page-editor-overview","title":"Page Editor Overview","text":"<p>The page editor has two modes:</p> <p>Visual Mode (default):</p> <ul> <li>Drag-and-drop interface (GrapesJS)</li> <li>No coding required</li> <li>What-you-see-is-what-you-get (WYSIWYG)</li> <li>Best for: Non-technical users, quick page creation</li> </ul> <p>Code Mode:</p> <ul> <li>HTML/CSS editor</li> <li>Full control over markup</li> <li>Best for: Experienced users, complex layouts</li> </ul> <p>Switch modes using the tabs at the top of the editor.</p> <p>Screenshot placeholder: Page editor showing Visual/Code mode tabs and toolbar</p> <p>Desktop Only</p> <p>The page editor is designed for desktop use (minimum 1024px width). Mobile users will see a warning to switch to desktop.</p>"},{"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":"<p>The visual editor has three main areas:</p> <p>1. Canvas (center):</p> <ul> <li>Preview of your page</li> <li>Click blocks to select</li> <li>Drag to reposition</li> </ul> <p>2. Block Toolbar (left):</p> <ul> <li>Drag blocks onto canvas</li> <li>Categories: Layout, Text, Media, Forms, Components</li> </ul> <p>3. Settings Panel (right):</p> <ul> <li>Style selected block</li> <li>Adjust colors, fonts, spacing</li> <li>Configure block settings</li> </ul> <p>Screenshot placeholder: Visual editor showing block toolbar, canvas, and settings panel</p>"},{"location":"v2/user-guides/content-editor-guide/#adding-blocks","title":"Adding Blocks","text":"<p>To add a block:</p> <ol> <li>Find block in left toolbar (or search)</li> <li>Drag block onto canvas</li> <li>Drop where you want it</li> </ol> <p>Available block categories:</p> <p>Layout:</p> <ul> <li>Section: Full-width container</li> <li>Container: Centered content wrapper</li> <li>Row: Multi-column row</li> <li>Column: Single column within row</li> </ul> <p>Text:</p> <ul> <li>Text: Paragraph text</li> <li>Heading: H1, H2, H3 headings</li> <li>Quote: Blockquote</li> <li>List: Bulleted or numbered list</li> </ul> <p>Media:</p> <ul> <li>Image: Single image</li> <li>Video: Embedded video (YouTube, Vimeo, or self-hosted)</li> <li>Icon: Font Awesome icon</li> </ul> <p>Forms:</p> <ul> <li>Form: Form container</li> <li>Input: Text input field</li> <li>Textarea: Multi-line text input</li> <li>Button: Submit button</li> </ul> <p>Components (custom blocks):</p> <ul> <li>Hero: Large header with background image and CTA</li> <li>Features: Three-column feature grid</li> <li>Testimonial: Quote with author photo</li> <li>Call to Action: Centered CTA with button</li> <li>Stats: Number counter grid</li> </ul> <p>Screenshot placeholder: Block toolbar showing categories and block preview thumbnails</p>"},{"location":"v2/user-guides/content-editor-guide/#configuring-blocks","title":"Configuring Blocks","text":"<p>To configure a block:</p> <ol> <li>Click the block on canvas (selects it)</li> <li>Settings panel opens on right</li> <li>Adjust settings (varies by block type)</li> </ol> <p>Common settings:</p> <p>Style tab:</p> <ul> <li>Typography: Font family, size, weight, color</li> <li>Spacing: Margin, padding</li> <li>Background: Color, image, gradient</li> <li>Border: Width, color, radius</li> <li>Dimensions: Width, height</li> </ul> <p>Settings tab (varies by block):</p> <ul> <li>Image: URL, alt text, link</li> <li>Video: Video URL, autoplay, controls</li> <li>Button: Text, link, style</li> <li>Form: Action URL, method</li> </ul> <p>Screenshot placeholder: Settings panel showing style options for a selected heading block</p>"},{"location":"v2/user-guides/content-editor-guide/#styling-blocks","title":"Styling Blocks","text":"<p>To change text color:</p> <ol> <li>Select text block</li> <li>Settings panel > Style tab</li> <li>Color picker under Typography</li> <li>Choose color or enter hex code</li> </ol> <p>To change background:</p> <ol> <li>Select section or container block</li> <li>Settings panel > Style tab</li> <li>Background section</li> <li>Choose color, image, or gradient</li> </ol> <p>To adjust spacing:</p> <ol> <li>Select block</li> <li>Settings panel > Style tab</li> <li>Margin/Padding section</li> <li>Adjust top, right, bottom, left values</li> </ol> <p>Screenshot placeholder: Background settings showing color picker, image upload, and gradient options</p>"},{"location":"v2/user-guides/content-editor-guide/#using-pre-built-components","title":"Using Pre-Built Components","text":"<p>Changemaker Lite includes pre-built components for common page sections:</p>"},{"location":"v2/user-guides/content-editor-guide/#hero-component","title":"Hero Component","text":"<p>What it is: Large header section with background image, headline, and call-to-action button</p> <p>How to use:</p> <ol> <li>Drag Hero block from Components category</li> <li>Click headline to edit text</li> <li>Click button to edit text and link</li> <li>Select block, then in settings:</li> <li>Upload background image</li> <li>Adjust overlay opacity</li> <li>Change text color</li> </ol> <p>Screenshot placeholder: Hero component on canvas showing headline, subheading, and CTA button</p>"},{"location":"v2/user-guides/content-editor-guide/#features-component","title":"Features Component","text":"<p>What it is: Three-column grid showcasing features or benefits</p> <p>How to use:</p> <ol> <li>Drag Features block onto canvas</li> <li>Click each feature to edit:</li> <li>Icon (Font Awesome icon name)</li> <li>Heading</li> <li>Description</li> <li>Adjust colors and spacing in settings panel</li> </ol> <p>Screenshot placeholder: Features component showing three columns with icons, headings, and text</p>"},{"location":"v2/user-guides/content-editor-guide/#testimonial-component","title":"Testimonial Component","text":"<p>What it is: Quote with author photo and name</p> <p>How to use:</p> <ol> <li>Drag Testimonial block onto canvas</li> <li>Click quote text to edit</li> <li>Click author name to edit</li> <li>Upload author photo in settings panel</li> </ol>"},{"location":"v2/user-guides/content-editor-guide/#call-to-action-component","title":"Call to Action Component","text":"<p>What it is: Centered section with headline and button</p> <p>How to use:</p> <ol> <li>Drag Call to Action block onto canvas</li> <li>Edit headline and description</li> <li>Edit button text and link</li> <li>Adjust background color</li> </ol>"},{"location":"v2/user-guides/content-editor-guide/#saving-your-page","title":"Saving Your Page","text":"<p>To save changes:</p> <p>Method 1: Keyboard shortcut</p> <ul> <li>Press Ctrl+S (Windows/Linux) or Cmd+S (Mac)</li> </ul> <p>Method 2: Save button</p> <ul> <li>Click \"Save\" button in editor toolbar</li> </ul> <p>Auto-save:</p> <ul> <li>Changes are NOT auto-saved</li> <li>Save frequently to avoid losing work</li> </ul> <p>Save Often</p> <p>Use Ctrl+S frequently. Browser crashes or network issues can cause unsaved work to be lost.</p> <p>Screenshot placeholder: Save button in editor toolbar</p>"},{"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":"<p>To switch to code editor:</p> <ol> <li>Click \"Code\" tab at top of editor</li> <li>HTML code appears in text editor</li> <li>Edit HTML directly</li> <li>Click \"Visual\" tab to return to visual mode</li> </ol> <p>When to use code mode:</p> <ul> <li>Need precise control over HTML structure</li> <li>Adding custom CSS or JavaScript</li> <li>Copying HTML from another source</li> <li>Working with complex layouts</li> </ul> <p>Screenshot placeholder: Code editor showing HTML markup in text editor</p>"},{"location":"v2/user-guides/content-editor-guide/#html-structure","title":"HTML Structure","text":"<p>Basic page structure:</p> <pre><code><!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</code></pre> <p>Recommended structure:</p> <pre><code><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</code></pre>"},{"location":"v2/user-guides/content-editor-guide/#adding-custom-css","title":"Adding Custom CSS","text":"<p>To add custom styles:</p> <ol> <li>In code mode, add a <code><style></code> block in the <code><head></code>:</li> </ol> <pre><code><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</code></pre>"},{"location":"v2/user-guides/content-editor-guide/#using-variables","title":"Using Variables","text":"<p>Landing pages support variable interpolation:</p> <p>Available variables:</p> <ul> <li><code>{{SITE_NAME}}</code> \u2014 Organization name (from settings)</li> <li><code>{{SITE_URL}}</code> \u2014 Website URL</li> <li><code>{{USER_NAME}}</code> \u2014 Logged-in user's name (if authenticated)</li> </ul> <p>Example usage:</p> <pre><code><p>Welcome to {{SITE_NAME}}, {{USER_NAME}}!</p>\n</code></pre> <p>Renders as:</p> <pre><code>Welcome to Community Action Network, John Smith!\n</code></pre>"},{"location":"v2/user-guides/content-editor-guide/#keyboard-shortcuts-in-code-mode","title":"Keyboard Shortcuts in Code Mode","text":"<ul> <li>Ctrl+S / Cmd+S: Save</li> <li>Ctrl+F / Cmd+F: Find</li> <li>Ctrl+H / Cmd+H: Find and replace</li> <li>Tab: Indent</li> <li>Shift+Tab: Unindent</li> </ul>"},{"location":"v2/user-guides/content-editor-guide/#publishing-pages","title":"Publishing Pages","text":""},{"location":"v2/user-guides/content-editor-guide/#publishing-workflow","title":"Publishing Workflow","text":"<p>Draft \u2192 Published:</p> <ol> <li>Create page (status: DRAFT)</li> <li>Build page in editor</li> <li>Preview page (see below)</li> <li>Publish page (change status to PUBLISHED)</li> </ol> <p>Draft pages:</p> <ul> <li>Not visible to public</li> <li>Only accessible to admins via direct URL</li> <li>Use for: Work in progress, testing</li> </ul> <p>Published pages:</p> <ul> <li>Visible at <code>/p/[slug]</code></li> <li>Accessible to anyone</li> <li>Indexed by search engines (if SEO configured)</li> </ul>"},{"location":"v2/user-guides/content-editor-guide/#previewing-pages","title":"Previewing Pages","text":"<p>To preview a page before publishing:</p> <ol> <li>Save the page (Ctrl+S)</li> <li>Click \"Preview\" button in editor toolbar</li> <li>Page opens in new tab at <code>/p/[slug]?preview=true</code></li> </ol> <p>OR:</p> <ol> <li>Navigate to Content > Landing Pages</li> <li>Click page title to view published version</li> </ol> <p>Screenshot placeholder: Preview button in editor toolbar</p>"},{"location":"v2/user-guides/content-editor-guide/#publishing-a-page","title":"Publishing a Page","text":"<p>To publish a draft page:</p> <ol> <li>Navigate to Content > Landing Pages</li> <li>Find the page in the table</li> <li>Click \"Edit\" in Actions column</li> <li>Change status from DRAFT to PUBLISHED</li> <li>Click \"Save\"</li> </ol> <p>To unpublish a page:</p> <ol> <li>Change status from PUBLISHED to DRAFT</li> <li>Save</li> </ol> <p>Unpublishing removes the page from public access but preserves all content.</p>"},{"location":"v2/user-guides/content-editor-guide/#seo-settings","title":"SEO Settings","text":"<p>To optimize for search engines:</p> <ol> <li>Edit the page</li> <li>Fill in SEO fields:</li> <li>Title: Page title (shown in search results, max 60 characters)</li> <li>Description: Meta description (shown in search results, max 160 characters)</li> <li>Keywords: Comma-separated keywords (e.g., \"climate action, advocacy, environment\")</li> <li>OG Image: Social media share image (Facebook, Twitter)</li> </ol> <p>Best practices:</p> <ul> <li>Title: Include primary keyword near beginning</li> <li>Description: Compelling, action-oriented, includes keyword</li> <li>Keywords: 5-10 relevant keywords</li> <li>OG Image: 1200x630 px, high-quality, relevant to page content</li> </ul> <p>Screenshot placeholder: SEO settings form showing title, description, keywords, and OG image fields</p>"},{"location":"v2/user-guides/content-editor-guide/#mkdocs-export","title":"MkDocs Export","text":"<p>What it is: Export landing page as Jinja2 template for MkDocs (static site generator)</p> <p>Use case: Publish landing pages on your static documentation site</p> <p>To export:</p> <ol> <li>Navigate to Content > Landing Pages</li> <li>Click \"Export\" in Actions column</li> <li>Choose export format:</li> <li>Jinja2 Template: Wraps HTML in MkDocs Material theme layout</li> <li>Standalone HTML: Raw HTML (no wrapper)</li> <li>File is saved to MkDocs <code>docs/overrides/</code> directory</li> <li>Access via MkDocs site navigation</li> </ol> <p>Screenshot placeholder: Export modal showing Jinja2/Standalone options</p>"},{"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":"<p>Email templates control the content and formatting of system-generated emails:</p> <p>System templates:</p> <ul> <li>Welcome Email: Sent to new users after registration</li> <li>Password Reset: Sent when user requests password reset</li> <li>Shift Confirmation: Sent when volunteer signs up for shift</li> <li>Shift Reminder: Sent day before shift</li> <li>Response Verification: Sent to verify campaign response</li> </ul> <p>Custom templates:</p> <ul> <li>Create custom templates for specific campaigns or events</li> <li>Use in shift emails, follow-up campaigns, etc.</li> </ul>"},{"location":"v2/user-guides/content-editor-guide/#email-template-structure","title":"Email Template Structure","text":"<p>Each template has three parts:</p> <p>1. Subject Line</p> <ul> <li>Text shown in email inbox</li> <li>Supports variables (e.g., <code>{{USER_NAME}}</code>, <code>{{SHIFT_TITLE}}</code>)</li> <li>Keep under 60 characters</li> </ul> <p>2. HTML Body</p> <ul> <li>Rich-formatted email (colors, images, links)</li> <li>What users see in modern email clients</li> <li>Supports variables</li> </ul> <p>3. Plain Text Body</p> <ul> <li>Unformatted text version</li> <li>Fallback for old email clients or user preference</li> <li>Should convey same information as HTML</li> </ul>"},{"location":"v2/user-guides/content-editor-guide/#editing-an-email-template","title":"Editing an Email Template","text":"<p>To edit a template:</p> <ol> <li>Navigate to Content > Email Templates</li> <li>Click \"Edit\" for the template you want to modify</li> <li>Edit subject, HTML body, and/or plain text body</li> <li>Click \"Preview\" to see rendered email</li> <li>Click \"Save\"</li> </ol> <p>Screenshot placeholder: Email template editor showing subject field, HTML editor, and plain text editor</p>"},{"location":"v2/user-guides/content-editor-guide/#using-variables-in-templates","title":"Using Variables in Templates","text":"<p>Variables are placeholders that get replaced with real data when the email is sent.</p> <p>Available variables:</p> <p>User variables:</p> <ul> <li><code>{{USER_NAME}}</code> \u2014 User's full name</li> <li><code>{{USER_EMAIL}}</code> \u2014 User's email address</li> </ul> <p>Shift variables:</p> <ul> <li><code>{{SHIFT_TITLE}}</code> \u2014 Shift name</li> <li><code>{{SHIFT_START}}</code> \u2014 Start date/time (formatted)</li> <li><code>{{SHIFT_END}}</code> \u2014 End date/time (formatted)</li> <li><code>{{SHIFT_LOCATION}}</code> \u2014 Meeting location</li> <li><code>{{SHIFT_CUT}}</code> \u2014 Cut name</li> </ul> <p>Campaign variables:</p> <ul> <li><code>{{CAMPAIGN_TITLE}}</code> \u2014 Campaign name</li> <li><code>{{CAMPAIGN_URL}}</code> \u2014 Link to campaign page</li> </ul> <p>System variables:</p> <ul> <li><code>{{SITE_NAME}}</code> \u2014 Organization name (from settings)</li> <li><code>{{SITE_URL}}</code> \u2014 Website URL</li> <li><code>{{RESET_LINK}}</code> \u2014 Password reset link (password reset emails only)</li> <li><code>{{VERIFICATION_LINK}}</code> \u2014 Verification link (response verification emails only)</li> </ul> <p>Example template:</p> <p>Subject:</p> <pre><code>Welcome to {{SITE_NAME}}, {{USER_NAME}}!\n</code></pre> <p>HTML Body:</p> <pre><code><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</code></pre> <p>Plain Text Body:</p> <pre><code>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</code></pre>"},{"location":"v2/user-guides/content-editor-guide/#html-email-best-practices","title":"HTML Email Best Practices","text":"<p>Do:</p> <ul> <li>\u2705 Use inline CSS (not external stylesheets)</li> <li>\u2705 Use tables for layout (old email clients don't support flexbox/grid)</li> <li>\u2705 Test in multiple email clients (Gmail, Outlook, Apple Mail)</li> <li>\u2705 Include alt text for images</li> <li>\u2705 Use web-safe fonts (Arial, Verdana, Georgia)</li> <li>\u2705 Keep width under 600px (mobile friendly)</li> <li>\u2705 Always provide plain text version</li> </ul> <p>Don't:</p> <ul> <li>\u274c Use JavaScript (email clients strip it)</li> <li>\u274c Use CSS positioning (absolute, fixed)</li> <li>\u274c Use background images (not universally supported)</li> <li>\u274c Rely on external resources (may be blocked)</li> <li>\u274c Use tiny fonts (< 14px)</li> </ul>"},{"location":"v2/user-guides/content-editor-guide/#testing-email-templates","title":"Testing Email Templates","text":"<p>To test a template:</p> <ol> <li>Click \"Send Test Email\" button in editor</li> <li>Enter your email address</li> <li>Click \"Send\"</li> <li>Check your inbox (may take 1-2 minutes)</li> </ol> <p>The test email uses sample data for variables:</p> <ul> <li><code>{{USER_NAME}}</code> \u2192 \"Test User\"</li> <li><code>{{SHIFT_TITLE}}</code> \u2192 \"Sample Shift\"</li> <li>etc.</li> </ul> <p>Test in multiple email clients:</p> <ul> <li>Gmail (web)</li> <li>Outlook (Windows)</li> <li>Apple Mail (Mac/iOS)</li> <li>Outlook.com (web)</li> </ul> <p>Look for:</p> <ul> <li>\u2705 Formatting intact (no broken layout)</li> <li>\u2705 Images loading</li> <li>\u2705 Links working</li> <li>\u2705 Variables replaced correctly</li> <li>\u2705 Readable on mobile (check phone)</li> </ul> <p>Screenshot placeholder: Send Test Email modal showing email address input and send button</p>"},{"location":"v2/user-guides/content-editor-guide/#managing-the-media-library","title":"Managing the Media Library","text":"<p>Optional Feature</p> <p>Media features must be enabled via Settings > Feature Toggles > ENABLE_MEDIA_FEATURES. Contact your administrator if this option is not visible.</p>"},{"location":"v2/user-guides/content-editor-guide/#media-library-overview","title":"Media Library Overview","text":"<p>The media library allows you to:</p> <ul> <li>Upload videos: MP4, MOV, AVI, MKV, WebM, M4V, FLV</li> <li>Organize by directory: Folder structure for categorization</li> <li>Edit metadata: Title, description, producer, creator, tags</li> <li>Share publicly: Publish videos to public gallery at <code>/media</code></li> <li>Lock videos: Prevent accidental deletion of important content</li> </ul> <p>Use cases:</p> <ul> <li>Event recordings (rallies, town halls, speeches)</li> <li>Testimonials (supporter stories)</li> <li>Educational content (issue explainers, how-to guides)</li> <li>Promotional videos (recruitment, fundraising appeals)</li> </ul>"},{"location":"v2/user-guides/content-editor-guide/#uploading-videos","title":"Uploading Videos","text":"<p>To upload a video:</p> <ol> <li>Navigate to Content > Media > Library</li> <li>Click \"Upload Video\" button (top-right)</li> <li>Either:</li> <li>Drag and drop video file into upload area, OR</li> <li>Click to browse and select file</li> <li>Fill in metadata (see below)</li> <li>Click \"Upload\"</li> </ol> <p>Screenshot placeholder: Upload Video modal showing drag-drop area and metadata form</p> <p>Supported formats:</p> <ul> <li>MP4 (recommended, best compatibility)</li> <li>MOV (Apple QuickTime)</li> <li>AVI (older format, large file size)</li> <li>MKV (Matroska, open format)</li> <li>WebM (web-optimized)</li> <li>M4V (Apple iTunes)</li> <li>FLV (Flash video, legacy)</li> </ul> <p>File size limit: 10 GB per file</p> <p>Upload time: Varies by file size and connection speed. A 1 GB file takes ~5-10 minutes on typical broadband.</p>"},{"location":"v2/user-guides/content-editor-guide/#video-metadata","title":"Video Metadata","text":"<p>Metadata fields:</p> <p>Title (required):</p> <ul> <li>Video title</li> <li>Displayed in library and public gallery</li> <li>Example: \"Climate Rally - June 2024\"</li> </ul> <p>Description (optional):</p> <ul> <li>Longer description of video content</li> <li>Supports HTML (bold, links, etc.)</li> <li>Displayed on video detail page</li> </ul> <p>Producer (optional):</p> <ul> <li>Organization or individual who produced the video</li> <li>Example: \"Community Action Network\"</li> </ul> <p>Creator (optional):</p> <ul> <li>Videographer or director</li> <li>Example: \"John Smith\"</li> </ul> <p>Tags (optional):</p> <ul> <li>Comma-separated keywords for search/filtering</li> <li>Example: \"climate, rally, 2024, toronto\"</li> </ul> <p>Directory (optional):</p> <ul> <li>Folder path for organization</li> <li>Use forward slashes for nested folders</li> <li>Examples: \"events/2024\", \"testimonials\", \"educational\"</li> </ul> <p>Screenshot placeholder: Metadata form showing title, description, producer, creator, tags, and directory fields</p>"},{"location":"v2/user-guides/content-editor-guide/#automatic-metadata-extraction","title":"Automatic Metadata Extraction","text":"<p>When you upload a video, the system automatically extracts:</p> <ul> <li>Duration: Length in seconds (shown as MM:SS)</li> <li>Dimensions: Width x height in pixels (e.g., 1920x1080)</li> <li>Orientation: PORTRAIT, LANDSCAPE, or SQUARE</li> <li>Quality: SD, HD, FULL_HD, or 4K</li> <li>Has Audio: Boolean (detected from audio track)</li> <li>File Size: Bytes (shown as MB/GB)</li> </ul> <p>Quality detection:</p> <ul> <li>SD (Standard Definition): Height < 720px</li> <li>HD (High Definition): Height 720-1079px</li> <li>FULL_HD (1080p): Height 1080-2159px</li> <li>4K (Ultra HD): Height \u2265 2160px</li> </ul> <p>Orientation detection:</p> <ul> <li>PORTRAIT: Height > Width (e.g., 1080x1920, vertical phone video)</li> <li>LANDSCAPE: Width > Height (e.g., 1920x1080, standard video)</li> <li>SQUARE: Width = Height (e.g., 1080x1080, Instagram video)</li> </ul> <p>You cannot edit these fields manually\u2014they're extracted automatically.</p>"},{"location":"v2/user-guides/content-editor-guide/#organizing-videos","title":"Organizing Videos","text":"<p>Directory structure:</p> <p>Use directories to organize videos by:</p> <ul> <li>Type: \"events\", \"testimonials\", \"educational\", \"promotional\"</li> <li>Year: \"2024\", \"2023\"</li> <li>Campaign: \"climate-campaign\", \"housing-campaign\"</li> <li>Combination: \"events/2024\", \"testimonials/climate\"</li> </ul> <p>Example directory structure:</p> <pre><code>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</code></pre> <p>To move videos between directories:</p> <ol> <li>Select videos in library (checkboxes)</li> <li>Choose \"Move\" from bulk actions</li> <li>Enter new directory path</li> <li>Click \"Move\"</li> </ol> <p>Screenshot placeholder: Library showing directory tree sidebar and video grid</p>"},{"location":"v2/user-guides/content-editor-guide/#filtering-and-searching-videos","title":"Filtering and Searching Videos","text":"<p>To find videos:</p> <p>Search:</p> <ul> <li>Enter keywords in search box</li> <li>Searches title, description, tags, producer, creator</li> </ul> <p>Filters:</p> <ul> <li>Directory: Show only videos in specific directory</li> <li>Quality: Filter by SD, HD, FULL_HD, 4K</li> <li>Orientation: Filter by portrait, landscape, square</li> <li>Locked: Show only locked or unlocked videos</li> </ul> <p>Sort:</p> <ul> <li>Upload date (newest first, oldest first)</li> <li>Title (A-Z, Z-A)</li> <li>Duration (shortest first, longest first)</li> </ul> <p>Screenshot placeholder: Library filters showing directory dropdown, quality checkboxes, and sort options</p>"},{"location":"v2/user-guides/content-editor-guide/#editing-video-metadata","title":"Editing Video Metadata","text":"<p>To edit a video:</p> <ol> <li>Click on video thumbnail (or click \"Edit\" in actions menu)</li> <li>Edit metadata fields</li> <li>Click \"Save\"</li> </ol> <p>Editable fields:</p> <ul> <li>Title</li> <li>Description</li> <li>Producer</li> <li>Creator</li> <li>Tags</li> <li>Directory</li> </ul> <p>Non-editable fields (auto-extracted):</p> <ul> <li>Duration</li> <li>Dimensions</li> <li>Orientation</li> <li>Quality</li> <li>Has Audio</li> <li>File Size</li> </ul>"},{"location":"v2/user-guides/content-editor-guide/#deleting-videos","title":"Deleting Videos","text":"<p>To delete a video:</p> <ol> <li>Select video in library</li> <li>Click \"Delete\" (trash icon)</li> <li>Confirm deletion</li> </ol> <p>Permanent Deletion</p> <p>Deleting a video is permanent. The video file is removed from the server and cannot be recovered.</p> <p>Locked videos cannot be deleted (unlock first).</p>"},{"location":"v2/user-guides/content-editor-guide/#locking-videos","title":"Locking Videos","text":"<p>What is locking?</p> <p>Locked videos cannot be:</p> <ul> <li>Deleted</li> <li>Moved to a different directory</li> <li>Unshared from public gallery (if already shared)</li> </ul> <p>When to lock:</p> <ul> <li>\u2705 Important historical videos</li> <li>\u2705 Videos currently shared publicly</li> <li>\u2705 Videos linked from landing pages or campaigns</li> </ul> <p>To lock a video:</p> <ol> <li>Select video</li> <li>Click \"Lock\" (padlock icon)</li> </ol> <p>To unlock:</p> <ol> <li>Select locked video</li> <li>Click \"Unlock\"</li> </ol> <p>Screenshot placeholder: Video card showing lock icon badge</p>"},{"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":"<p>The public media gallery (<code>/media</code>) showcases videos to the public. It's organized by categories.</p> <p>Categories:</p> <ul> <li>TESTIMONIAL: Personal stories from supporters</li> <li>EVENT: Rally videos, town halls, speeches</li> <li>EDUCATIONAL: Issue explainers, how-to guides</li> <li>PROMOTIONAL: Recruitment, fundraising appeals</li> </ul>"},{"location":"v2/user-guides/content-editor-guide/#sharing-videos","title":"Sharing Videos","text":"<p>To share videos publicly:</p> <ol> <li>Navigate to Content > Media > Shared Media</li> <li>Click \"Share Videos\" button</li> <li>Select videos from library (search, filter, select)</li> <li>Choose category (TESTIMONIAL, EVENT, EDUCATIONAL, PROMOTIONAL)</li> <li>Click \"Share\"</li> </ol> <p>Videos immediately appear on public gallery at <code>/media</code>.</p> <p>Screenshot placeholder: Share Videos modal showing library selector, category dropdown, and share button</p>"},{"location":"v2/user-guides/content-editor-guide/#managing-shared-media","title":"Managing Shared Media","text":"<p>To view shared videos:</p> <ol> <li>Navigate to Content > Media > Shared Media</li> </ol> <p>Table shows:</p> <ul> <li>Video title</li> <li>Category</li> <li>Shared date</li> <li>View count (if tracking enabled)</li> <li>Actions: Unshare, change category</li> </ul> <p>To unshare videos:</p> <ol> <li>Select videos in table</li> <li>Click \"Unshare\"</li> <li>Confirm</li> </ol> <p>Videos are removed from public gallery but remain in library.</p> <p>To change category:</p> <ol> <li>Click \"Edit\" for video</li> <li>Select new category</li> <li>Click \"Save\"</li> </ol>"},{"location":"v2/user-guides/content-editor-guide/#public-gallery-customization","title":"Public Gallery Customization","text":"<p>Gallery settings (managed by admin):</p> <ul> <li>Gallery title (e.g., \"Our Videos\")</li> <li>Category order</li> <li>Videos per page</li> <li>Allow reactions (like, love, etc.)</li> </ul> <p>Ask your administrator to configure these settings.</p>"},{"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":"<p>Scannable:</p> <ul> <li>Use headings and subheadings</li> <li>Short paragraphs (2-3 sentences)</li> <li>Bulleted lists</li> <li>Bold key points</li> </ul> <p>Actionable:</p> <ul> <li>Clear call to action on every page</li> <li>Tell users what to do next</li> <li>Use action verbs (Join, Donate, Sign Up, Learn More)</li> </ul> <p>Accessible:</p> <ul> <li>Use alt text for images</li> <li>Sufficient color contrast (WCAG AA: 4.5:1 for text)</li> <li>Descriptive link text (not \"click here\")</li> <li>Readable font size (\u2265 16px)</li> </ul>"},{"location":"v2/user-guides/content-editor-guide/#mobile-optimization","title":"Mobile Optimization","text":"<p>Mobile traffic is 50-70% of web traffic. Optimize for mobile:</p> <p>Responsive design:</p> <ul> <li>Use mobile-friendly templates</li> <li>Test on actual phones (not just desktop browser resize)</li> </ul> <p>Touch targets:</p> <ul> <li>Buttons at least 44x44 px</li> <li>Adequate spacing between links (avoid accidental taps)</li> </ul> <p>Load time:</p> <ul> <li>Compress images (use tools like TinyPNG)</li> <li>Minimize video file sizes</li> <li>Avoid large background images</li> </ul> <p>Readability:</p> <ul> <li>Large font (\u2265 16px)</li> <li>Short paragraphs</li> <li>Simple navigation</li> </ul>"},{"location":"v2/user-guides/content-editor-guide/#seo-optimization","title":"SEO Optimization","text":"<p>On-page SEO:</p> <ol> <li>Title tag: Include primary keyword, under 60 characters</li> <li>Meta description: Compelling, includes keyword, under 160 characters</li> <li>Headings: Use H1 for main title, H2 for sections, H3 for subsections</li> <li>Keywords: Use naturally in content (don't stuff)</li> <li>Internal links: Link to other pages on your site</li> <li>External links: Link to authoritative sources</li> <li>Image alt text: Describe images for screen readers and SEO</li> </ol> <p>Technical SEO:</p> <ul> <li>Fast load time (< 3 seconds)</li> <li>Mobile-friendly</li> <li>HTTPS (secure)</li> <li>Clean URLs (e.g., <code>/p/about-us</code>, not <code>/p/page?id=123</code>)</li> </ul>"},{"location":"v2/user-guides/content-editor-guide/#accessibility","title":"Accessibility","text":"<p>WCAG 2.1 Level AA compliance:</p> <p>Perceivable:</p> <ul> <li>Alt text for images</li> <li>Captions for videos</li> <li>Color contrast (4.5:1 for text, 3:1 for large text)</li> </ul> <p>Operable:</p> <ul> <li>Keyboard navigation (all interactive elements reachable via Tab)</li> <li>Skip links (skip to main content)</li> <li>No keyboard traps</li> </ul> <p>Understandable:</p> <ul> <li>Clear language (avoid jargon)</li> <li>Consistent navigation</li> <li>Error messages explain how to fix</li> </ul> <p>Robust:</p> <ul> <li>Valid HTML (no unclosed tags, proper nesting)</li> <li>Semantic markup (use <code><nav></code>, <code><main></code>, <code><article></code>, not just <code><div></code>)</li> </ul>"},{"location":"v2/user-guides/content-editor-guide/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/user-guides/content-editor-guide/#landing-pages","title":"Landing Pages","text":"<p>Issue: Page editor won't load</p> <p>Solutions:</p> <ol> <li>Check browser console for errors (F12)</li> <li>Try different browser (Chrome recommended)</li> <li>Clear browser cache (Ctrl+Shift+Delete)</li> <li>Disable browser extensions (ad blockers may interfere)</li> </ol> <p>Issue: Changes not saving</p> <p>Solutions:</p> <ol> <li>Check internet connection</li> <li>Try Ctrl+S (keyboard shortcut)</li> <li>Check browser console for errors</li> <li>Try refreshing and re-editing</li> </ol> <p>Issue: Page looks different when published</p> <p>Causes:</p> <ul> <li>Preview mode shows editor styles (not exact public view)</li> <li>Browser caching old version</li> </ul> <p>Solutions:</p> <ol> <li>Hard refresh published page (Ctrl+Shift+R)</li> <li>Test in incognito/private window</li> <li>Clear browser cache</li> </ol>"},{"location":"v2/user-guides/content-editor-guide/#email-templates","title":"Email Templates","text":"<p>Issue: Variables not replacing</p> <p>Symptoms: Email shows <code>{{USER_NAME}}</code> instead of actual name</p> <p>Causes:</p> <ul> <li>Variable name misspelled</li> <li>Variable not supported in this template type</li> <li>Email sent via test (test uses sample data)</li> </ul> <p>Solutions:</p> <ol> <li>Check variable spelling (case-sensitive)</li> <li>Consult variable reference (see \"Using Variables\" above)</li> <li>Send real email (not test) to see actual data</li> </ol> <p>Issue: Email looks broken in Outlook</p> <p>Causes: Outlook uses Microsoft Word rendering engine (poor CSS support)</p> <p>Solutions:</p> <ol> <li>Use table-based layout (not flexbox/grid)</li> <li>Use inline CSS (not external styles)</li> <li>Test specifically in Outlook (use Litmus or Email on Acid)</li> </ol>"},{"location":"v2/user-guides/content-editor-guide/#media-library","title":"Media Library","text":"<p>Issue: Video won't upload</p> <p>Solutions:</p> <ol> <li>Check file size (max 10 GB)</li> <li>Check file format (must be MP4, MOV, AVI, MKV, WebM, M4V, or FLV)</li> <li>Check internet connection (large files need stable connection)</li> <li>Try different browser</li> </ol> <p>Issue: Metadata extraction failed</p> <p>Symptoms: Duration shows \"Unknown\", quality shows \"N/A\"</p> <p>Causes:</p> <ul> <li>Video file is corrupted</li> <li>Unsupported codec</li> <li>FFprobe service not running (server issue)</li> </ul> <p>Solutions:</p> <ol> <li>Try re-encoding video (use HandBrake or similar)</li> <li>Convert to MP4 with H.264 codec (most compatible)</li> <li>Contact administrator (may be server configuration issue)</li> </ol> <p>Issue: Video won't play on public gallery</p> <p>Causes:</p> <ul> <li>Video not shared (still in library only)</li> <li>Unsupported codec in browser</li> <li>Video file missing (deleted from server)</li> </ul> <p>Solutions:</p> <ol> <li>Verify video is shared (Content > Media > Shared Media)</li> <li>Re-encode as H.264 MP4 (best browser compatibility)</li> <li>Check server logs (ask administrator)</li> </ol>"},{"location":"v2/user-guides/content-editor-guide/#related-documentation","title":"Related Documentation","text":"<ul> <li>Admin Guide: Full administrator guide</li> <li>Campaign Manager Guide: Campaign-specific content (email templates)</li> <li>Landing Pages Feature: Technical documentation on GrapesJS editor</li> <li>Media Library Feature: Technical documentation on video upload and storage</li> <li>API Reference: Pages API endpoints</li> </ul> <p>Last updated: February 2026 (V2 complete)</p>"},{"location":"v2/user-guides/map-organizer-guide/","title":"Map Organizer Guide","text":""},{"location":"v2/user-guides/map-organizer-guide/#overview","title":"Overview","text":"<p>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:</p> <ul> <li>Import and manage locations: Build your canvassing database from CSV or NAR data</li> <li>Create territorial cuts: Divide your area into manageable canvassing zones</li> <li>Organize volunteer shifts: Schedule and coordinate door-to-door canvassing</li> <li>Monitor canvass progress: Track coverage, outcomes, and volunteer performance</li> <li>Ensure data quality: Review geocoding accuracy and fix issues</li> <li>Generate walk sheets: Create printable canvassing materials</li> </ul> <p>Whether you're organizing a local ward campaign or a city-wide canvass, this guide provides strategies for effective territory management.</p>"},{"location":"v2/user-guides/map-organizer-guide/#understanding-map-roles","title":"Understanding Map Roles","text":"<p>You may have one of two roles for map management:</p>"},{"location":"v2/user-guides/map-organizer-guide/#super_admin","title":"SUPER_ADMIN","text":"<ul> <li>Access: Full platform access</li> <li>Capabilities: All map functions plus user management, campaigns, site settings</li> <li>Use case: Primary administrator</li> </ul>"},{"location":"v2/user-guides/map-organizer-guide/#map_admin","title":"MAP_ADMIN","text":"<ul> <li>Access: Map module only</li> <li>Capabilities:</li> <li>Import and manage locations</li> <li>Create cuts</li> <li>Organize shifts</li> <li>Monitor canvassing</li> <li>Generate walk sheets</li> <li>Restrictions: Cannot manage users (except shift assignments), campaigns, or site settings</li> <li>Use case: Dedicated field organizer without full admin access</li> </ul> <p>Role Specialization</p> <p>If you only manage field operations (not campaigns), ask for MAP_ADMIN role. This keeps the interface focused on your work.</p>"},{"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":"<p>A location is a physical address where canvassing occurs. Each location represents:</p> <ul> <li>A single-family home, OR</li> <li>An apartment/condo building (multi-unit), OR</li> <li>A business address (if canvassing businesses)</li> </ul> <p>Location data includes:</p> <ul> <li>Address: Full civic address (street, city, province, postal code)</li> <li>Coordinates: Latitude and longitude (from geocoding)</li> <li>Building type: RESIDENTIAL, APARTMENT, BUSINESS</li> <li>Unit count: Number of dwelling units (1 for houses, 10+ for apartments)</li> <li>Cut assignment: Which territorial cut the location belongs to</li> <li>Canvass history: Past visits, outcomes, support levels</li> </ul>"},{"location":"v2/user-guides/map-organizer-guide/#building-vs-unit-level","title":"Building vs Unit Level","text":"<p>Building-level data (recommended):</p> <ul> <li>One location record per building</li> <li><code>unitCount</code> field indicates multi-unit buildings</li> <li>Example: \"123 Main St\" with <code>unitCount: 24</code> (apartment building)</li> </ul> <p>Unit-level data (alternative):</p> <ul> <li>One location record per unit</li> <li>Example: \"123 Main St, Unit 1\", \"123 Main St, Unit 2\", etc.</li> <li>More granular but creates more records</li> </ul> <p>Recommended Approach</p> <p>Use building-level data for apartments (one record with <code>unitCount</code>). This reduces database size and simplifies canvassing (volunteers visit building once, not once per unit).</p>"},{"location":"v2/user-guides/map-organizer-guide/#data-sources","title":"Data Sources","text":"<p>1. CSV Import \u2014 Your own data</p> <ul> <li>Volunteer sign-up forms</li> <li>Voter registration data</li> <li>Membership lists</li> <li>Custom databases</li> </ul> <p>2. NAR Import \u2014 Canadian electoral data</p> <ul> <li>Elections Canada National Address Register</li> <li>All residential addresses in Canada</li> <li>Pre-geocoded coordinates</li> <li>Federal electoral districts</li> </ul> <p>3. Manual Entry \u2014 Individual addresses</p> <ul> <li>Add one location at a time via admin interface</li> <li>Click-to-add on map</li> </ul>"},{"location":"v2/user-guides/map-organizer-guide/#importing-locations-from-csv","title":"Importing Locations from CSV","text":""},{"location":"v2/user-guides/map-organizer-guide/#preparing-your-csv-file","title":"Preparing Your CSV File","text":"<p>Required columns:</p> <ul> <li><code>address</code> \u2014 Full street address (e.g., \"123 Main St\")</li> <li><code>city</code> \u2014 City name (e.g., \"Ottawa\")</li> <li><code>province</code> \u2014 Province/state code (e.g., \"ON\", \"BC\")</li> <li><code>postalCode</code> \u2014 Postal code (e.g., \"K1A 0B1\")</li> </ul> <p>Optional columns:</p> <ul> <li><code>latitude</code> \u2014 Pre-geocoded latitude (decimal degrees)</li> <li><code>longitude</code> \u2014 Pre-geocoded longitude (decimal degrees)</li> <li><code>buildingType</code> \u2014 RESIDENTIAL, APARTMENT, or BUSINESS</li> <li><code>unitCount</code> \u2014 Number of units (integer, default: 1)</li> <li><code>federalDistrict</code> \u2014 Electoral district name</li> <li><code>notes</code> \u2014 Internal notes</li> </ul> <p>CSV example:</p> <pre><code>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</code></pre> <p>CSV formatting tips:</p> <ol> <li>Use quotes around addresses with commas</li> <li>Remove special characters (emoji, unusual symbols)</li> <li>Use UTF-8 encoding (not Windows-1252 or ASCII)</li> <li>One header row (first row = column names)</li> <li>No blank rows (delete empty rows at end)</li> <li>Consistent province codes (use 2-letter abbreviations)</li> </ol> <p>Excel to CSV:</p> <ol> <li>Open your Excel file</li> <li>File > Save As</li> <li>Format: \"CSV UTF-8 (Comma delimited) (*.csv)\"</li> <li>Save</li> </ol>"},{"location":"v2/user-guides/map-organizer-guide/#importing-the-csv","title":"Importing the CSV","text":"<p>To import locations:</p> <ol> <li>Navigate to Map > Locations</li> <li>Click \"Import CSV\" button (top-right)</li> <li>Upload your CSV file (drag-drop or browse)</li> <li>Map CSV columns to location fields</li> <li>Preview imported data (first 10 rows shown)</li> <li>Click \"Import\"</li> </ol> <p>Screenshot placeholder: CSV import dialog showing file upload area and column mapping interface</p> <p>Column mapping:</p> <p>The system tries to auto-detect columns, but verify:</p> <ul> <li>CSV \"address\" \u2192 Location \"address\"</li> <li>CSV \"city\" \u2192 Location \"city\"</li> <li>CSV \"province\" \u2192 Location \"province\"</li> <li>CSV \"postalCode\" \u2192 Location \"postalCode\"</li> </ul> <p>If your CSV uses different column names (e.g., \"Street Address\" instead of \"address\"), map manually using the dropdowns.</p> <p>What happens during import:</p> <ol> <li>System validates each row (checks required fields)</li> <li>Skips invalid rows (logs errors)</li> <li>Creates location records</li> <li>Geocodes addresses (if lat/lng not provided)</li> <li>Shows summary: X imported, Y skipped</li> </ol> <p>Import limits:</p> <ul> <li>Maximum 10,000 rows per import</li> <li>For larger datasets, split into multiple files</li> </ul>"},{"location":"v2/user-guides/map-organizer-guide/#troubleshooting-import-issues","title":"Troubleshooting Import Issues","text":"<p>Issue: \"Invalid CSV format\"</p> <p>Causes:</p> <ul> <li>File is not actually CSV (e.g., Excel .xlsx)</li> <li>Missing header row</li> <li>Inconsistent column count (some rows have more/fewer columns)</li> </ul> <p>Solutions:</p> <ul> <li>Save as CSV UTF-8 from Excel</li> <li>Ensure first row is headers</li> <li>Remove blank rows and columns</li> </ul> <p>Issue: \"Missing required field\"</p> <p>Causes:</p> <ul> <li>CSV missing required column (address, city, province, or postalCode)</li> <li>Column name doesn't match (e.g., \"Street\" instead of \"address\")</li> </ul> <p>Solutions:</p> <ul> <li>Add missing column to CSV</li> <li>Use column mapping to map \"Street\" \u2192 \"address\"</li> </ul> <p>Issue: \"Geocoding failed for X addresses\"</p> <p>Causes:</p> <ul> <li>Addresses are invalid (typos, wrong format)</li> <li>Addresses are too vague (\"Main Street\" without number)</li> <li>Geocoding service is down</li> </ul> <p>Solutions:</p> <ul> <li>Review failed addresses in Data Quality dashboard</li> <li>Fix typos and re-import those rows</li> <li>Manually place locations on map (see below)</li> </ul>"},{"location":"v2/user-guides/map-organizer-guide/#nar-import-canadian-electoral-data","title":"NAR Import (Canadian Electoral Data)","text":""},{"location":"v2/user-guides/map-organizer-guide/#what-is-nar-data","title":"What is NAR Data?","text":"<p>NAR (National Address Register) is Elections Canada's official database of all residential addresses in Canada. It includes:</p> <ul> <li>Precise civic addresses (from Address files)</li> <li>Geocoded coordinates (from Location files)</li> <li>Federal electoral districts</li> <li>Building use classification (residential, commercial, institutional)</li> </ul> <p>Advantages:</p> <ul> <li>\u2705 Comprehensive (all Canadian addresses)</li> <li>\u2705 Pre-geocoded (high accuracy)</li> <li>\u2705 Includes federal district data</li> <li>\u2705 Updated regularly by Elections Canada</li> </ul> <p>Disadvantages:</p> <ul> <li>\u274c Canada only (not available for other countries)</li> <li>\u274c Requires server access to install data files</li> <li>\u274c Large file size (multi-GB for provinces like Ontario)</li> </ul>"},{"location":"v2/user-guides/map-organizer-guide/#obtaining-nar-data","title":"Obtaining NAR Data","text":"<p>NAR data must be obtained from Elections Canada:</p> <ol> <li>Contact Elections Canada Open Data team</li> <li>Request latest NAR dataset (e.g., \"NAR 2025 Server\")</li> <li>Download Address and Location files</li> <li>Provide files to your system administrator</li> </ol> <p>Files needed:</p> <ul> <li><code>Address_[province]_part_[X].csv</code> \u2014 Civic addresses</li> <li><code>Location_[province].csv</code> \u2014 Geocoded coordinates</li> </ul> <p>System administrator places files in <code>/data</code> directory on server.</p>"},{"location":"v2/user-guides/map-organizer-guide/#importing-nar-data","title":"Importing NAR Data","text":"<p>To import NAR data:</p> <ol> <li>Navigate to Map > Locations</li> <li>Click \"NAR Import\" button</li> <li>Select province (e.g., Ontario)</li> <li>Choose dataset (if multiple years available)</li> <li>Apply filters (see below)</li> <li>Click \"Start Import\"</li> </ol> <p>Screenshot placeholder: NAR Import modal showing province selector, dataset picker, and filter options</p> <p>Import filters:</p> <p>Province filter (required):</p> <ul> <li>Select province to import (ON, BC, AB, etc.)</li> <li>Each province has separate Address/Location files</li> </ul> <p>City filter (optional):</p> <ul> <li>Import only specific cities</li> <li>Example: \"Toronto,Ottawa,Mississauga\" (comma-separated)</li> <li>Leave blank to import entire province</li> </ul> <p>Postal code filter (optional):</p> <ul> <li>Import only specific postal code prefixes</li> <li>Example: \"K1A,K1B,K1C\" (forward sortation areas)</li> <li>Useful for targeting specific neighborhoods</li> </ul> <p>Cut filter (optional):</p> <ul> <li>Assign imported locations to a specific cut</li> <li>If left blank, locations are imported without cut assignment</li> <li>You can assign to cuts later</li> </ul> <p>Residential only (toggle):</p> <ul> <li>ON: Import only residential buildings (exclude commercial, institutional)</li> <li>OFF: Import all buildings</li> <li>Recommended: ON (unless you're canvassing businesses)</li> </ul> <p>What happens during NAR import:</p> <ol> <li>System scans NAR files for selected province</li> <li>Joins Address and Location files on <code>LOC_GUID</code> (internal Elections Canada ID)</li> <li>Filters by city, postal code (if specified)</li> <li>Converts coordinates from EPSG:3347 (Lambert projection) to WGS84 (lat/lng)</li> <li>Creates location records</li> <li>Shows progress (can take several minutes for large provinces)</li> </ol> <p>Import performance:</p> <ul> <li>Small municipality (10k addresses): ~30 seconds</li> <li>Large city (500k addresses): ~5 minutes</li> <li>Full province (3M addresses): ~20 minutes</li> </ul> <p>Server-Side Processing</p> <p>NAR import runs on the server (not in your browser). Do not close the modal during import\u2014wait for completion message.</p>"},{"location":"v2/user-guides/map-organizer-guide/#nar-data-fields","title":"NAR Data Fields","text":"<p>NAR import populates these location fields:</p> <ul> <li><code>address</code> \u2014 From Address file: <code>CIVIC_NO + OFFICIAL_STREET_NAME + STREET_TYPE + STREET_DIRECTION</code></li> <li><code>city</code> \u2014 From Address file: <code>MUNICIPALITY_NAME</code></li> <li><code>province</code> \u2014 From province code</li> <li><code>postalCode</code> \u2014 From Address file: <code>POSTAL_CODE</code></li> <li><code>latitude</code> \u2014 From Location file: <code>BG_LATITUDE</code> (converted to WGS84)</li> <li><code>longitude</code> \u2014 From Location file: <code>BG_LONGITUDE</code> (converted to WGS84)</li> <li><code>federalDistrict</code> \u2014 From Location file: <code>FED_NUM</code> (district number) + name lookup</li> <li><code>buildingUse</code> \u2014 From Address file: <code>BUILDING_USE</code> (RESIDENTIAL, COMMERCIAL, INSTITUTIONAL)</li> </ul>"},{"location":"v2/user-guides/map-organizer-guide/#creating-and-managing-cuts","title":"Creating and Managing Cuts","text":""},{"location":"v2/user-guides/map-organizer-guide/#what-is-a-cut","title":"What is a Cut?","text":"<p>A cut is a geographic area used to organize canvassing. Cuts are polygons drawn on a map.</p> <p>Common cut types:</p> <ul> <li>WARD: Municipal electoral ward</li> <li>NEIGHBORHOOD: Informal neighborhood (e.g., \"Downtown\", \"Riverside\")</li> <li>DISTRICT: Federal or provincial electoral district</li> <li>CUSTOM: Any other boundary (e.g., \"North of Highway\", \"Priority Zone\")</li> </ul> <p>Why use cuts?</p> <ul> <li>\u2705 Assign territories to volunteers: \"You canvass Ward 5\"</li> <li>\u2705 Track progress by area: \"Ward 5 is 75% complete\"</li> <li>\u2705 Generate walk sheets: Print addresses for Ward 5 only</li> <li>\u2705 Prevent duplication: Volunteers know their boundaries</li> </ul>"},{"location":"v2/user-guides/map-organizer-guide/#cut-best-practices","title":"Cut Best Practices","text":"<p>Size:</p> <ul> <li>Recommended: 200-500 locations per cut</li> <li>Too small (< 100): Inefficient (volunteers finish too quickly)</li> <li>Too large (> 1000): Overwhelming (takes many sessions to complete)</li> </ul> <p>Boundaries:</p> <ul> <li>Use natural boundaries: Roads, rivers, parks, rail lines</li> <li>Avoid cutting through neighborhoods arbitrarily</li> <li>Use official boundaries when available (ward maps, district maps)</li> </ul> <p>Naming:</p> <ul> <li>Use official names when available (\"Ward 5\", \"Riverdale\")</li> <li>Be consistent (don't mix \"Ward 5\" and \"Fifth Ward\")</li> <li>Avoid abbreviations unless universally understood</li> </ul> <p>Colors:</p> <ul> <li>Use distinct colors for adjacent cuts</li> <li>Use color coding meaningfully (e.g., priority cuts in red)</li> <li>Ensure colors are visible on both light and dark backgrounds</li> </ul>"},{"location":"v2/user-guides/map-organizer-guide/#creating-a-cut-drawing-on-map","title":"Creating a Cut (Drawing on Map)","text":"<p>To create a cut:</p> <ol> <li>Navigate to Map > Cuts</li> <li>Click the \"Map Drawing\" tab</li> <li>Click \"Start Drawing\"</li> <li>Click on the map to add polygon vertices</li> <li>Close the polygon (click near first vertex)</li> <li>Fill in cut details (see form below)</li> <li>Click \"Save Cut\"</li> </ol> <p>Screenshot placeholder: Cut drawing interface showing map with polygon being drawn</p> <p>Drawing tips:</p> <ol> <li>Start at a corner: Begin at a distinct landmark (intersection, park corner)</li> <li>Follow roads: Click along roads and boundaries</li> <li>Use zoom: Zoom in for precision, out for overview</li> <li>Closing detection: System detects when you're near the first point and offers to close</li> <li>Undo: Click \"Undo Last Point\" if you make a mistake</li> </ol> <p>Cut form fields:</p> <p>Name (required):</p> <ul> <li>Cut identifier (e.g., \"Ward 5\", \"Downtown\")</li> <li>Displayed on map, walk sheets, volunteer portal</li> </ul> <p>Category (required):</p> <ul> <li>WARD, NEIGHBORHOOD, DISTRICT, or CUSTOM</li> <li>Used for filtering and organizing</li> </ul> <p>Color (required):</p> <ul> <li>Display color on map</li> <li>Use color picker or enter hex code (#FF5733)</li> </ul> <p>Description (optional):</p> <ul> <li>Internal notes about the cut</li> <li>Example: \"Priority area, high support expected\"</li> </ul> <p>Screenshot placeholder: Cut creation form showing name, category, color picker, and description</p>"},{"location":"v2/user-guides/map-organizer-guide/#automatic-location-assignment","title":"Automatic Location Assignment","text":"<p>When you save a cut, the system automatically:</p> <ol> <li>Checks which locations fall inside the polygon (point-in-polygon algorithm)</li> <li>Assigns those locations to the cut</li> <li>Shows count: \"X locations assigned\"</li> </ol> <p>Re-assignment:</p> <ul> <li>Locations can only belong to one cut</li> <li>If you draw overlapping cuts, later cuts override earlier assignments</li> <li>Review location table to verify assignments</li> </ul>"},{"location":"v2/user-guides/map-organizer-guide/#editing-cuts","title":"Editing Cuts","text":"<p>To edit a cut:</p> <ol> <li>Navigate to Map > Cuts</li> <li>Click \"Edit\" in Actions column</li> <li>Modify name, category, color, or description</li> <li>Click \"Save\"</li> </ol> <p>Note: You cannot edit the polygon shape after creation. To change boundaries, delete the cut and redraw.</p> <p>To delete a cut:</p> <ol> <li>Click \"Delete\" in Actions column</li> <li>Confirm deletion</li> </ol> <p>What happens to locations?</p> <ul> <li>Cut assignment is removed (locations become unassigned)</li> <li>Locations are NOT deleted</li> <li>Historical canvass data is preserved (visits remain linked to coordinates)</li> </ul>"},{"location":"v2/user-guides/map-organizer-guide/#managing-locations","title":"Managing Locations","text":""},{"location":"v2/user-guides/map-organizer-guide/#viewing-and-filtering-locations","title":"Viewing and Filtering Locations","text":"<p>To view all locations:</p> <ol> <li>Navigate to Map > Locations</li> </ol> <p>The locations table shows:</p> <ul> <li>Address: Full civic address</li> <li>City: City name</li> <li>Cut: Assigned cut (if any)</li> <li>Geocoded: \u2705 (has coordinates) or \u274c (needs geocoding)</li> <li>Last Visit: Date of most recent canvass visit</li> <li>Actions: Edit, delete</li> </ul> <p>Filters:</p> <ul> <li>Search: Search by address or postal code</li> <li>Cut: Filter to specific cut</li> <li>Geocoded: Show only geocoded or ungeocoded</li> <li>Building Type: Filter by RESIDENTIAL, APARTMENT, BUSINESS</li> <li>Date Added: Filter by import/creation date</li> </ul> <p>Screenshot placeholder: Locations table with search bar, cut filter, and geocoded status column</p>"},{"location":"v2/user-guides/map-organizer-guide/#editing-a-location","title":"Editing a Location","text":"<p>To edit a location:</p> <ol> <li>Click \"Edit\" in Actions column</li> <li>Modify fields (see below)</li> <li>Click \"Save\"</li> </ol> <p>Editable fields:</p> <p>Address details:</p> <ul> <li>Street address</li> <li>City</li> <li>Province</li> <li>Postal code</li> </ul> <p>Coordinates:</p> <ul> <li>Latitude (decimal degrees, e.g., 45.4215)</li> <li>Longitude (decimal degrees, e.g., -75.6972)</li> <li>Drag map pin to adjust visually</li> </ul> <p>Metadata:</p> <ul> <li>Building type (RESIDENTIAL, APARTMENT, BUSINESS)</li> <li>Unit count (integer)</li> <li>Federal district (text)</li> <li>Notes (internal notes)</li> </ul> <p>Cut assignment:</p> <ul> <li>Select cut from dropdown</li> <li>Or leave blank (unassigned)</li> </ul> <p>Screenshot placeholder: Edit Location modal showing address fields, map with draggable pin, and metadata fields</p>"},{"location":"v2/user-guides/map-organizer-guide/#manually-placing-locations-on-map","title":"Manually Placing Locations on Map","text":"<p>If geocoding fails, you can manually place a location:</p> <ol> <li>Edit the location</li> <li>Use the map at the bottom of the form</li> <li>Drag the red pin to the correct position</li> <li>Latitude and longitude fields update automatically</li> <li>Click \"Save\"</li> </ol> <p>Tip: Use satellite view or street view to identify exact building location.</p>"},{"location":"v2/user-guides/map-organizer-guide/#bulk-operations","title":"Bulk Operations","text":"<p>To perform bulk actions:</p> <ol> <li>Select locations (checkboxes in table)</li> <li>Choose action from \"Bulk Actions\" dropdown:</li> <li>Assign to Cut: Assign selected locations to a cut</li> <li>Geocode: Re-geocode selected locations</li> <li>Delete: Delete selected locations</li> <li>Confirm action</li> </ol> <p>Screenshot placeholder: Bulk actions dropdown with selected locations and action buttons</p>"},{"location":"v2/user-guides/map-organizer-guide/#deleting-locations","title":"Deleting Locations","text":"<p>To delete locations:</p> <ol> <li>Select locations in table (or filter and select all)</li> <li>Choose \"Delete\" from bulk actions</li> <li>Confirm deletion</li> </ol> <p>Canvass History Preserved</p> <p>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.</p>"},{"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":"<p>Geocoding converts addresses to latitude/longitude coordinates for map display.</p> <p>Why geocoding matters:</p> <ul> <li>Locations without coordinates cannot appear on map</li> <li>Inaccurate coordinates place locations in wrong areas</li> <li>Poor geocoding affects canvassing efficiency (volunteers can't find addresses)</li> </ul>"},{"location":"v2/user-guides/map-organizer-guide/#geocoding-providers","title":"Geocoding Providers","text":"<p>Changemaker Lite tries multiple geocoding providers in order:</p> <ol> <li>Nominatim (OpenStreetMap) \u2014 Free, no API key, global coverage</li> <li>ArcGIS \u2014 Free tier, accurate for North America</li> <li>Photon \u2014 Free, Europe-focused</li> <li>Mapbox \u2014 Requires API key, very accurate</li> <li>Google Geocoding \u2014 Requires API key, most accurate</li> <li>LocationIQ \u2014 Requires API key, Nominatim-based</li> </ol> <p>How it works:</p> <ul> <li>System tries Nominatim first</li> <li>If confidence < 0.5, tries next provider</li> <li>If all fail, location remains ungeocoded</li> </ul> <p>API keys (optional, configured by admin):</p> <ul> <li>Mapbox: <code>MAPBOX_API_KEY</code></li> <li>Google: <code>GOOGLE_MAPS_API_KEY</code></li> <li>LocationIQ: <code>LOCATIONIQ_API_KEY</code></li> </ul> <p>Without API keys, only free providers (Nominatim, ArcGIS, Photon) are used.</p>"},{"location":"v2/user-guides/map-organizer-guide/#geocode-confidence-levels","title":"Geocode Confidence Levels","text":"<p>Each geocoded location has a confidence score (0.0 to 1.0):</p> <ul> <li>0.9-1.0: High confidence (exact address match)</li> <li>0.7-0.9: Medium-high confidence (likely correct)</li> <li>0.5-0.7: Medium confidence (street or area match)</li> <li>0.3-0.5: Low confidence (approximate)</li> <li>0.0-0.3: Very low confidence (city or region only)</li> </ul> <p>Confidence affects accuracy:</p> <ul> <li>High confidence \u2192 Pin is at exact building</li> <li>Low confidence \u2192 Pin may be at street midpoint or city center</li> </ul>"},{"location":"v2/user-guides/map-organizer-guide/#data-quality-dashboard","title":"Data Quality Dashboard","text":"<p>To review geocoding quality:</p> <ol> <li>Navigate to Map > Data Quality</li> </ol> <p>The dashboard shows:</p> <p>Statistics cards:</p> <ul> <li>Total locations: All location records</li> <li>Geocoded: Locations with coordinates</li> <li>Ungeocoded: Locations without coordinates</li> <li>Low confidence: Confidence < 0.5</li> <li>Medium confidence: Confidence 0.5-0.8</li> <li>High confidence: Confidence > 0.8</li> </ul> <p>Geocoding provider breakdown:</p> <ul> <li>Chart showing which providers geocoded how many locations</li> <li>Example: 60% Nominatim, 30% ArcGIS, 10% Mapbox</li> </ul> <p>Confidence distribution:</p> <ul> <li>Histogram showing confidence score distribution</li> <li>Identify patterns (many low-confidence addresses?)</li> </ul> <p>Action items:</p> <ul> <li>Re-geocode low confidence: Button to retry with different provider</li> <li>Export ungeocoded: CSV of failed addresses</li> <li>Manual review: Link to locations table filtered for low confidence</li> </ul> <p>Screenshot placeholder: Data Quality Dashboard showing statistics cards, provider pie chart, and confidence histogram</p>"},{"location":"v2/user-guides/map-organizer-guide/#improving-geocoding-quality","title":"Improving Geocoding Quality","text":"<p>Strategy 1: Fix Address Typos</p> <ol> <li>Export ungeocoded locations (CSV)</li> <li>Review addresses in Excel</li> <li>Fix typos, formatting errors</li> <li>Re-import corrected CSV</li> </ol> <p>Common issues:</p> <ul> <li>Missing civic number (\"Main Street\" \u2192 \"123 Main Street\")</li> <li>Misspelled street name (\"Mane St\" \u2192 \"Main St\")</li> <li>Wrong province (\"ON\" \u2192 \"BC\")</li> </ul> <p>Strategy 2: Re-geocode with Better Provider</p> <ol> <li>Configure API keys for Mapbox or Google (ask admin)</li> <li>Select low-confidence locations</li> <li>Click \"Geocode Selected\" (bulk action)</li> <li>System retries with all available providers</li> </ol> <p>Strategy 3: Manually Place Locations</p> <ol> <li>Filter locations with confidence < 0.5</li> <li>Edit each location</li> <li>Find correct position on map (use satellite view)</li> <li>Drag pin to correct location</li> <li>Save</li> </ol> <p>Strategy 4: Use NAR Data (Canada Only)</p> <p>NAR data includes pre-geocoded coordinates with very high accuracy. If you imported from CSV and have poor geocoding, consider switching to NAR import.</p>"},{"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":"<p>A shift is a scheduled volunteer canvassing session. Shifts have:</p> <ul> <li>Title: Name of the canvass (e.g., \"Saturday Morning Canvass - Ward 5\")</li> <li>Start/End Time: When volunteers should arrive and finish</li> <li>Cut Assignment: Which area to canvass (optional but recommended)</li> <li>Max Signups: Capacity limit (0 = unlimited)</li> <li>Meeting Location: Where volunteers meet before canvassing</li> </ul> <p>Why shifts matter:</p> <ul> <li>\u2705 Coordinate volunteers: Everyone knows when and where to show up</li> <li>\u2705 Track assignments: Volunteers see \"their\" shifts in portal</li> <li>\u2705 Enable canvassing: Volunteers can only start canvass sessions if they have a shift</li> <li>\u2705 Measure progress: See which shifts generated most visits</li> </ul>"},{"location":"v2/user-guides/map-organizer-guide/#creating-a-shift","title":"Creating a Shift","text":"<p>To create a shift:</p> <ol> <li>Navigate to Map > Shifts</li> <li>Click \"Create Shift\"</li> <li>Fill in shift details (see below)</li> <li>Click \"Create\"</li> </ol> <p>Shift fields:</p> <p>Title (required):</p> <ul> <li>Descriptive name</li> <li>Include date, time, and area</li> <li>Example: \"Saturday Morning Canvass - Ward 5\"</li> </ul> <p>Description (optional):</p> <ul> <li>Additional details for volunteers</li> <li>Example: \"Bring water, comfortable shoes. We'll provide clipboards and walk sheets.\"</li> </ul> <p>Start Time (required):</p> <ul> <li>Date and time picker</li> <li>When volunteers should arrive</li> </ul> <p>End Time (required):</p> <ul> <li>Expected end time</li> <li>Helps volunteers plan their day</li> </ul> <p>Cut (optional but recommended):</p> <ul> <li>Select which cut to canvass</li> <li>Volunteers assigned to this shift will see this cut in their portal</li> <li>Shifts without cuts cannot be canvassed</li> </ul> <p>Cut Assignment Required for Canvassing</p> <p>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.</p> <p>Max Signups (optional):</p> <ul> <li>Capacity limit (e.g., 10 volunteers)</li> <li>Set to 0 for unlimited</li> <li>Useful for managing group size</li> </ul> <p>Meeting Location (optional):</p> <ul> <li>Address or description of meeting point</li> <li>Example: \"Community Centre, 123 Main St\" or \"Corner of Main & Oak\"</li> </ul> <p>Screenshot placeholder: Create Shift form showing date/time picker, cut dropdown, capacity field, and meeting location</p>"},{"location":"v2/user-guides/map-organizer-guide/#managing-shift-signups","title":"Managing Shift Signups","text":"<p>To view shift signups:</p> <ol> <li>Navigate to Map > Shifts</li> <li>Click \"Signups\" in Actions column for a shift</li> </ol> <p>The signups drawer shows:</p> <p>Capacity gauge:</p> <ul> <li>Current signups / Max signups</li> <li>Example: \"8 / 10 signups (80% full)\"</li> </ul> <p>Signup list:</p> <ul> <li>Volunteer name</li> <li>Email</li> <li>Role (USER or TEMP)</li> <li>Signup date</li> <li>Actions: Remove signup, upgrade TEMP to USER</li> </ul> <p>Signup sources:</p> <ol> <li>Public signup form (<code>/shifts</code> page):</li> <li>Anyone can sign up</li> <li>Creates TEMP user account automatically</li> <li> <p>Sends confirmation email</p> </li> <li> <p>Admin-added:</p> </li> <li>You manually add volunteers</li> <li> <p>Select existing users or create new</p> </li> <li> <p>Volunteer portal:</p> </li> <li>USER-role volunteers sign up themselves</li> <li>See My Shifts page in their portal</li> </ol> <p>Screenshot placeholder: Shift Signups drawer showing capacity gauge and signup list</p>"},{"location":"v2/user-guides/map-organizer-guide/#adding-volunteers-to-a-shift","title":"Adding Volunteers to a Shift","text":"<p>To manually add a volunteer:</p> <ol> <li>Click \"Signups\" for the shift</li> <li>Click \"Add Volunteer\"</li> <li>Select existing user from dropdown (or click \"Create New User\")</li> <li>Click \"Add\"</li> </ol> <p>Upgrading TEMP users to USER:</p> <p>After a TEMP user attends their first shift:</p> <ol> <li>Open shift signups</li> <li>Find the TEMP user</li> <li>Click \"Upgrade to USER\"</li> <li>Confirm</li> </ol> <p>This gives them full canvassing access for future shifts.</p>"},{"location":"v2/user-guides/map-organizer-guide/#emailing-shift-volunteers","title":"Emailing Shift Volunteers","text":"<p>To email all volunteers in a shift:</p> <ol> <li>Click \"Signups\" for the shift</li> <li>Click \"Email All\"</li> <li>Compose email:</li> <li>Subject</li> <li>Body (HTML supported)</li> <li>Variables: <code>{{NAME}}</code>, <code>{{SHIFT_TITLE}}</code>, <code>{{SHIFT_START}}</code>, <code>{{MEETING_LOCATION}}</code></li> <li>Click \"Send\"</li> </ol> <p>Common email scenarios:</p> <p>Reminder (day before shift):</p> <pre><code>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</code></pre> <p>Cancellation (weather, etc.):</p> <pre><code>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</code></pre> <p>Follow-up (after shift):</p> <pre><code>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</code></pre> <p>Screenshot placeholder: Email Shift Volunteers modal showing subject, body editor, and variable buttons</p>"},{"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":"<p>A walk sheet is a printed list of addresses for door-to-door canvassing. It includes:</p> <ul> <li>Cut name and statistics</li> <li>QR code (volunteers scan to start canvass session)</li> <li>List of addresses in walking order</li> <li>Fields for volunteers to record outcomes</li> </ul>"},{"location":"v2/user-guides/map-organizer-guide/#walk-sheet-settings","title":"Walk Sheet Settings","text":"<p>To configure walk sheet defaults:</p> <ol> <li>Navigate to Map > Map Settings</li> <li>Scroll to \"Walk Sheet Configuration\"</li> <li>Set:</li> <li>Header Text: Organization name, campaign info</li> <li>Footer Text: Contact info, instructions</li> <li>Include QR Code: Toggle ON/OFF</li> <li>QR Code Size: Small, medium, large</li> <li>Instructions: How to use the walk sheet</li> </ol> <p>Example header:</p> <pre><code>Community Action Network\nFall 2024 Canvass\nContact: organizer@example.com | (555) 123-4567\n</code></pre> <p>Example footer:</p> <pre><code>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</code></pre> <p>Screenshot placeholder: Map Settings page showing walk sheet configuration section</p>"},{"location":"v2/user-guides/map-organizer-guide/#generating-a-walk-sheet","title":"Generating a Walk Sheet","text":"<p>To generate a walk sheet for a cut:</p> <ol> <li>Navigate to Canvass > Walk Sheet</li> <li>Select cut from dropdown</li> <li>Click \"Generate\"</li> <li>Review PDF preview</li> <li>Click \"Print\" or \"Download PDF\"</li> </ol> <p>OR:</p> <ol> <li>Navigate to Map > Locations</li> <li>Filter to specific cut</li> <li>Click \"Walk Sheet\" button (top-right)</li> </ol> <p>Walk sheet contents:</p> <p>Page 1:</p> <ul> <li>Header (from settings)</li> <li>Cut name and statistics:</li> <li>Total locations</li> <li>Last visit summary</li> <li>Completion percentage</li> <li>QR code (links to <code>/volunteer/canvass/[cutId]</code>)</li> <li>Instructions (from settings)</li> <li>Cut map (small overview map)</li> </ul> <p>Subsequent pages:</p> <ul> <li>Address table:</li> <li>Street address</li> <li>Unit count (if apartment building)</li> <li>Last visit date (if previously canvassed)</li> <li>Last outcome (if previously canvassed)</li> <li>Blank fields for volunteers to fill:<ul> <li>Date visited</li> <li>Outcome</li> <li>Support level</li> <li>Notes</li> </ul> </li> </ul> <p>Screenshot placeholder: Walk sheet PDF showing header, QR code, map, and address table</p>"},{"location":"v2/user-guides/map-organizer-guide/#walking-order-optimization","title":"Walking Order Optimization","text":"<p>Walk sheets sort addresses in walking order to minimize backtracking.</p> <p>Algorithm:</p> <ol> <li>Start at center of cut</li> <li>Find nearest unvisited address</li> <li>Move to that address</li> <li>Repeat until all addresses covered</li> </ol> <p>This creates an efficient route similar to the GPS route in the volunteer portal.</p>"},{"location":"v2/user-guides/map-organizer-guide/#using-walk-sheets-in-the-field","title":"Using Walk Sheets in the Field","text":"<p>Distribute to volunteers:</p> <ol> <li>Print one walk sheet per volunteer (or per pair, if canvassing in pairs)</li> <li>Bring clipboards and pens</li> <li>Brief volunteers on how to record outcomes</li> </ol> <p>Volunteers record:</p> <ul> <li>Date visited</li> <li>Outcome code (NH, R, SW, etc.)</li> <li>Support level (S1-S4 if spoke with)</li> <li>Notes (brief comments)</li> </ul> <p>After the canvass:</p> <ol> <li>Collect completed walk sheets</li> <li>Enter data into system (or scan QR code during canvass for automatic recording)</li> </ol>"},{"location":"v2/user-guides/map-organizer-guide/#monitoring-canvass-progress","title":"Monitoring Canvass Progress","text":""},{"location":"v2/user-guides/map-organizer-guide/#canvass-dashboard","title":"Canvass Dashboard","text":"<p>To view overall canvass progress:</p> <ol> <li>Navigate to Canvass > Dashboard</li> </ol> <p>The dashboard shows:</p> <p>Statistics cards:</p> <ul> <li>Active sessions: Volunteers currently canvassing</li> <li>Total visits today: Doors knocked today</li> <li>Completed sessions: Finished sessions today</li> <li>Average session duration: Time spent canvassing</li> </ul> <p>Activity feed:</p> <ul> <li>Real-time stream of visits</li> <li>Shows: Volunteer name, address, outcome, timestamp</li> <li>Updates every 30 seconds</li> </ul> <p>Cut progress table:</p> <ul> <li>Progress by cut (% of locations visited)</li> <li>Session count per cut</li> <li>Visit count per cut</li> <li>Click cut name to view details</li> </ul> <p>Leaderboard:</p> <ul> <li>Top volunteers by visit count</li> <li>Session count</li> <li>Success rate (% SPOKE_WITH outcomes)</li> </ul> <p>Screenshot placeholder: Canvass Dashboard showing stats cards, activity feed, cut progress table, and leaderboard</p>"},{"location":"v2/user-guides/map-organizer-guide/#cut-level-progress","title":"Cut-Level Progress","text":"<p>To view progress for a specific cut:</p> <ol> <li>Navigate to Canvass > Dashboard</li> <li>Click cut name in cut progress table</li> </ol> <p>Cut detail view shows:</p> <ul> <li>Completion gauge: % of locations visited</li> <li>Outcome breakdown: Pie chart of outcomes (NOT_HOME, REFUSED, SPOKE_WITH, etc.)</li> <li>Support levels: Count of LEVEL_1 through LEVEL_4</li> <li>Visit history: Recent visits in this cut</li> <li>Active sessions: Volunteers currently canvassing this cut</li> </ul> <p>Export cut data:</p> <ul> <li>Click \"Export CSV\" to download all visits for this cut</li> <li>Use for analysis, reporting, follow-up planning</li> </ul>"},{"location":"v2/user-guides/map-organizer-guide/#session-monitoring","title":"Session Monitoring","text":"<p>To view active canvass sessions:</p> <ol> <li>Navigate to Canvass > Dashboard</li> <li>Scroll to \"Active Sessions\" section</li> </ol> <p>Each active session shows:</p> <ul> <li>Volunteer name</li> <li>Cut being canvassed</li> <li>Start time</li> <li>Visit count</li> <li>Last activity (how long since last visit)</li> </ul> <p>Warning signs:</p> <ul> <li>\u26a0\ufe0f No activity for > 30 minutes (volunteer may be stuck or abandoned session)</li> <li>\u26a0\ufe0f Very low visit rate (volunteer may need help)</li> </ul> <p>Actions:</p> <ul> <li>Contact volunteer to check in</li> <li>Manually end session if abandoned</li> </ul>"},{"location":"v2/user-guides/map-organizer-guide/#data-analysis-and-reporting","title":"Data Analysis and Reporting","text":""},{"location":"v2/user-guides/map-organizer-guide/#outcome-analysis","title":"Outcome Analysis","text":"<p>To understand canvassing results:</p> <ol> <li>Navigate to Canvass > Dashboard</li> <li>View Outcome Breakdown chart</li> </ol> <p>Outcome categories:</p> <ul> <li>NOT_HOME: Nobody answered (typical: 40-60% of visits)</li> <li>REFUSED: Refused to talk (typical: 5-15%)</li> <li>SPOKE_WITH: Had a conversation (typical: 20-40%)</li> <li>MOVED_AWAY: Resident moved (typical: 2-5%)</li> <li>WRONG_ADDRESS: Address doesn't exist (typical: 1-3%)</li> <li>DO_NOT_CONTACT: Requested no contact (typical: < 1%)</li> <li>OTHER: Other situation (typical: < 5%)</li> </ul> <p>Interpreting outcomes:</p> <p>High NOT_HOME rate (> 60%):</p> <ul> <li>Canvassing at wrong time (try evenings or weekends)</li> <li>Multi-unit buildings (hard to access)</li> </ul> <p>High REFUSED rate (> 20%):</p> <ul> <li>Issue is unpopular or controversial</li> <li>Volunteers may need better training on approach</li> <li>Consider different messaging</li> </ul> <p>Low SPOKE_WITH rate (< 20%):</p> <ul> <li>See above (related to NOT_HOME and REFUSED)</li> <li>Canvassing at wrong time</li> <li>Poor volunteer approach</li> </ul> <p>High WRONG_ADDRESS (> 5%):</p> <ul> <li>Data quality issues</li> <li>Need to clean location database</li> </ul>"},{"location":"v2/user-guides/map-organizer-guide/#support-level-analysis","title":"Support Level Analysis","text":"<p>To understand voter sentiment:</p> <ol> <li>View Support Levels on Canvass Dashboard</li> </ol> <p>Support level breakdown:</p> <ul> <li>LEVEL_1 (Strong support): Target for GOTV (Get Out The Vote)</li> <li>LEVEL_2 (Leaning support): Persuasion targets</li> <li>LEVEL_3 (Undecided): Persuasion targets</li> <li>LEVEL_4 (Opposition): Deprioritize future contact</li> </ul> <p>Targeting strategy:</p> <p>For GOTV:</p> <ul> <li>Focus on LEVEL_1 (strong support)</li> <li>Ensure they vote (door knock day before election, offer rides)</li> </ul> <p>For persuasion:</p> <ul> <li>Focus on LEVEL_2 and LEVEL_3 (undecided, leaning)</li> <li>Provide information, answer questions, invite to events</li> </ul> <p>For opposition:</p> <ul> <li>LEVEL_4: Don't waste time (respect their decision)</li> </ul>"},{"location":"v2/user-guides/map-organizer-guide/#volunteer-performance","title":"Volunteer Performance","text":"<p>To evaluate volunteer effectiveness:</p> <ol> <li>View Leaderboard on Canvass Dashboard</li> </ol> <p>Metrics:</p> <ul> <li>Visit count: Total doors knocked</li> <li>Session count: Number of canvassing sessions</li> <li>Success rate: % of visits that resulted in SPOKE_WITH outcome</li> <li>Average session duration: Time spent canvassing</li> </ul> <p>Identifying top performers:</p> <ul> <li>High visit count + high success rate = Star volunteer (recognize publicly, ask to mentor others)</li> <li>High visit count + low success rate = May be rushing (provide feedback)</li> <li>Low visit count + high success rate = Quality over quantity (consider assigning harder areas)</li> </ul> <p>Coaching opportunities:</p> <ul> <li>Low success rate: Offer training on approach, scripting</li> <li>Short sessions: Ask why (time constraints? Lack of confidence?)</li> <li>High REFUSED rate: Review volunteer's approach (too pushy? Poor messaging?)</li> </ul>"},{"location":"v2/user-guides/map-organizer-guide/#troubleshooting","title":"Troubleshooting","text":""},{"location":"v2/user-guides/map-organizer-guide/#geocoding-issues","title":"Geocoding Issues","text":"<p>Issue: Many locations ungeocoded after import</p> <p>Solutions:</p> <ol> <li>Review ungeocoded addresses (Data Quality > Export Ungeocoded)</li> <li>Fix typos and re-import</li> <li>Configure additional geocoding API keys (Mapbox, Google)</li> <li>Manually place locations on map</li> </ol> <p>Issue: Locations geocoded to wrong area</p> <p>Symptoms: Locations appear far from where they should be</p> <p>Solutions:</p> <ol> <li>Check confidence score (likely low confidence)</li> <li>Edit location and manually place on map</li> <li>Re-geocode with better provider (if API key available)</li> </ol>"},{"location":"v2/user-guides/map-organizer-guide/#cut-issues","title":"Cut Issues","text":"<p>Issue: Locations not assigning to cut</p> <p>Symptoms: Locations inside polygon not assigned after cut creation</p> <p>Solutions:</p> <ol> <li>Verify polygon is properly closed (check vertices)</li> <li>Check for very complex polygons (may hit algorithm limits)</li> <li>Manually assign locations using bulk action</li> </ol> <p>Issue: Overlapping cuts</p> <p>Symptoms: Some locations assigned to wrong cut</p> <p>Cause: Multiple cuts cover the same area</p> <p>Solution:</p> <ul> <li>Locations can only belong to one cut</li> <li>Later cuts override earlier assignments</li> <li>Redraw cuts to avoid overlap, OR</li> <li>Accept overlap and use manual assignment for edge cases</li> </ul>"},{"location":"v2/user-guides/map-organizer-guide/#shift-issues","title":"Shift Issues","text":"<p>Issue: Volunteer cannot start canvass session</p> <p>Symptoms: \"No active shift found\" error</p> <p>Solutions:</p> <ol> <li>Verify shift date is today</li> <li>Verify volunteer is signed up for shift</li> <li>Verify shift has a cut assigned (required for canvassing)</li> <li>Verify volunteer role is USER (not TEMP)</li> </ol> <p>Issue: Shift signups not appearing</p> <p>Symptoms: Public signup form doesn't show shift</p> <p>Solutions:</p> <ol> <li>Check shift start time (past shifts don't appear)</li> <li>Check max signups (if full, shift is hidden)</li> <li>Check feature toggle (Settings > Allow Public Shift Signup must be ON)</li> </ol>"},{"location":"v2/user-guides/map-organizer-guide/#canvassing-issues","title":"Canvassing Issues","text":"<p>Issue: Walking route not updating</p> <p>Symptoms: Route doesn't change after completing visits</p> <p>Solutions:</p> <ol> <li>Route updates every 30 seconds (wait a moment)</li> <li>Refresh volunteer's map (pull down)</li> <li>Check internet connection (route calculation requires server)</li> </ol> <p>Issue: Visit won't save</p> <p>Symptoms: Volunteer reports \"Save Visit\" doesn't work</p> <p>Solutions:</p> <ol> <li>Check internet connection (visits save to server)</li> <li>Verify outcome is selected (required field)</li> <li>Check for abandoned session (volunteer may need to start new session)</li> </ol>"},{"location":"v2/user-guides/map-organizer-guide/#related-documentation","title":"Related Documentation","text":"<ul> <li>Admin Guide: Full administrator guide (includes map management)</li> <li>Volunteer Guide: Guide for volunteers using canvassing portal</li> <li>Map Module: Technical documentation on locations, geocoding, cuts</li> <li>Canvassing System: Technical documentation on canvass sessions and GPS tracking</li> <li>API Reference: Map API endpoints</li> </ul> <p>Last updated: February 2026 (V2 complete)</p>"},{"location":"v2/user-guides/volunteer-guide/","title":"Volunteer Guide","text":""},{"location":"v2/user-guides/volunteer-guide/#overview","title":"Overview","text":"<p>Welcome to Changemaker Lite! As a volunteer, you'll use the volunteer portal to:</p> <ul> <li>View your assigned shifts: See upcoming canvassing shifts you've signed up for</li> <li>Canvas neighborhoods: Go door-to-door talking to voters</li> <li>Record visit outcomes: Track who you spoke with and their responses</li> <li>Navigate efficiently: Use GPS and walking routes to cover your territory</li> <li>Track your activity: View your canvassing history and statistics</li> </ul> <p>This guide will help you get started and make the most of your canvassing time.</p>"},{"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":"<p>There are two ways to get a volunteer account:</p>"},{"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":"<ol> <li>Visit the public shifts page (your organizer will send you the link)</li> <li>Find a shift that works for your schedule</li> <li>Click \"Sign Up\"</li> <li>Fill in:</li> <li>Your name</li> <li>Your email address</li> <li>Phone number (optional)</li> <li>Click \"Confirm Signup\"</li> </ol> <p>You'll receive a confirmation email with your temporary login credentials.</p> <p>Temporary Accounts</p> <p>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.</p>"},{"location":"v2/user-guides/volunteer-guide/#option-2-admin-creates-your-account","title":"Option 2: Admin Creates Your Account","text":"<p>Your organizer may create an account for you directly. You'll receive a welcome email with:</p> <ul> <li>Your login email address</li> <li>A temporary password</li> <li>Instructions to change your password on first login</li> </ul> <p>Screenshot placeholder: Shift signup form showing name, email, and phone fields</p>"},{"location":"v2/user-guides/volunteer-guide/#logging-in","title":"Logging In","text":"<p>To access the volunteer portal:</p> <ol> <li>Go to your organization's login page (usually <code>https://app.yourorg.org</code>)</li> <li>Enter your email address</li> <li>Enter your password</li> <li>Click \"Log In\"</li> </ol> <p>After logging in, you'll be automatically redirected to the volunteer dashboard at <code>/volunteer</code>.</p> <p>Remember Me</p> <p>Check \"Remember me\" to stay logged in for 7 days. Only do this on your personal device.</p> <p>Screenshot placeholder: Login page with email/password fields and \"Remember me\" checkbox</p>"},{"location":"v2/user-guides/volunteer-guide/#first-login-change-your-password","title":"First Login: Change Your Password","text":"<p>If you received a temporary password, change it immediately:</p> <ol> <li>After logging in, click your email in the top-right corner</li> <li>Select \"Change Password\"</li> <li>Enter your temporary password</li> <li>Enter new password (must meet requirements)</li> <li>Confirm new password</li> <li>Click \"Update Password\"</li> </ol> <p>Password requirements:</p> <ul> <li>Minimum 12 characters</li> <li>At least one uppercase letter (A-Z)</li> <li>At least one lowercase letter (a-z)</li> <li>At least one digit (0-9)</li> </ul> <p>Screenshot placeholder: Change password modal showing current/new password fields</p>"},{"location":"v2/user-guides/volunteer-guide/#volunteer-dashboard-overview","title":"Volunteer Dashboard Overview","text":"<p>Your volunteer dashboard shows:</p> <p>Top Navigation:</p> <ul> <li>Dashboard \u2014 Overview and quick stats</li> <li>My Shifts \u2014 Upcoming and past shifts</li> <li>My Activity \u2014 Canvassing history and statistics</li> <li>My Routes \u2014 Maps of areas you've canvassed</li> </ul> <p>Dashboard Cards:</p> <ul> <li>Upcoming Shifts: Next 3 shifts you're signed up for</li> <li>Your Statistics: Total visits, doors knocked, support found</li> <li>Recent Activity: Last 10 visits you recorded</li> <li>Quick Start: Button to start canvassing if you have an active shift</li> </ul> <p>Screenshot placeholder: Volunteer dashboard showing statistics cards and upcoming shifts list</p>"},{"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":"<p>To view all your shifts:</p> <ol> <li>Click \"My Shifts\" in the top navigation</li> </ol> <p>The shifts page shows two tabs:</p>"},{"location":"v2/user-guides/volunteer-guide/#upcoming-shifts","title":"Upcoming Shifts","text":"<p>Shows shifts you're signed up for that haven't happened yet.</p> <p>Each shift card shows:</p> <ul> <li>Shift title: Name of the canvass</li> <li>Date and time: When to arrive</li> <li>Meeting location: Where to meet (address or description)</li> <li>Cut assignment: Which area you'll be canvassing</li> <li>Other volunteers: Who else signed up (if visible)</li> <li>Actions: Cancel signup, view details, get directions</li> </ul> <p>Screenshot placeholder: Upcoming shifts showing three shift cards with date, time, and location</p>"},{"location":"v2/user-guides/volunteer-guide/#past-shifts","title":"Past Shifts","text":"<p>Shows shifts you've completed or that have passed.</p> <p>Each past shift shows:</p> <ul> <li>Shift details</li> <li>Your attendance (if tracked)</li> <li>Number of visits you recorded</li> <li>Session duration</li> </ul> <p>Screenshot placeholder: Past shifts showing completed shift cards with visit counts</p>"},{"location":"v2/user-guides/volunteer-guide/#shift-details","title":"Shift Details","text":"<p>To view shift details:</p> <ol> <li>Click on a shift card</li> <li>View:</li> <li>Full description</li> <li>Map of the cut you'll canvass</li> <li>List of other volunteers (if visible)</li> <li>Instructions from organizer</li> <li>QR code to start canvassing (if you arrive early)</li> </ol> <p>Screenshot placeholder: Shift detail modal showing map, description, and volunteer list</p>"},{"location":"v2/user-guides/volunteer-guide/#canceling-a-signup","title":"Canceling a Signup","text":"<p>To cancel a shift signup:</p> <ol> <li>Find the shift in My Shifts > Upcoming</li> <li>Click \"Cancel Signup\"</li> <li>Confirm cancellation</li> </ol> <p>Cancel Early</p> <p>Please cancel at least 24 hours before the shift if possible. Your organizer needs time to find a replacement.</p> <p>You'll receive a confirmation email when you cancel.</p>"},{"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":"<p>You can start canvassing in two ways:</p>"},{"location":"v2/user-guides/volunteer-guide/#method-1-from-dashboard-if-shift-is-today","title":"Method 1: From Dashboard (If Shift is Today)","text":"<ol> <li>Go to Volunteer Dashboard</li> <li>If you have a shift today, you'll see a \"Start Canvassing\" button</li> <li>Click the button</li> <li>Select which shift you're canvassing for (if you have multiple)</li> <li>Click \"Start Session\"</li> </ol>"},{"location":"v2/user-guides/volunteer-guide/#method-2-from-my-shifts","title":"Method 2: From My Shifts","text":"<ol> <li>Go to My Shifts</li> <li>Find today's shift</li> <li>Click \"Start Canvassing\"</li> </ol>"},{"location":"v2/user-guides/volunteer-guide/#method-3-scan-qr-code-walk-sheet","title":"Method 3: Scan QR Code (Walk Sheet)","text":"<p>If your organizer gave you a printed walk sheet:</p> <ol> <li>Open your phone's camera app</li> <li>Point at the QR code on the walk sheet</li> <li>Tap the notification that appears</li> <li>Your browser will open and start the session automatically</li> </ol> <p>Screenshot placeholder: Start canvassing button on dashboard with shift selector dropdown</p> <p>One Session at a Time</p> <p>You can only have one active session. Finish your current session before starting a new one.</p>"},{"location":"v2/user-guides/volunteer-guide/#understanding-the-canvass-map","title":"Understanding the Canvass Map","text":"<p>When you start a session, you'll see a full-screen map with:</p> <p>Map Elements:</p> <ol> <li>Your location (blue dot with accuracy circle)</li> <li>Updates as you move</li> <li> <p>Accuracy circle shows GPS precision</p> </li> <li> <p>Locations to visit (house icons)</p> </li> <li>Gray house: Not visited yet</li> <li>Yellow house: You visited, outcome recorded</li> <li>Red house: Refused to talk</li> <li>Green house: Supportive (LEVEL_1 or LEVEL_2)</li> <li> <p>Blue house: Not home</p> </li> <li> <p>Walking route (purple line)</p> </li> <li>Suggested path connecting unvisited locations</li> <li>Updates as you complete visits</li> <li> <p>Follow the line for efficient canvassing</p> </li> <li> <p>Cut boundary (colored polygon)</p> </li> <li>Your assigned territory</li> <li>Don't canvass outside this area</li> </ol> <p>Screenshot placeholder: Canvass map showing blue location dot, house icons in different colors, and purple walking route</p>"},{"location":"v2/user-guides/volunteer-guide/#map-controls","title":"Map Controls","text":"<p>Top-left controls:</p> <ul> <li>Menu (hamburger icon): Open navigation drawer</li> <li>Center on me (target icon): Re-center map on your location</li> <li>Fullscreen (expand icon): Enter fullscreen mode</li> </ul> <p>Bottom toolbar:</p> <ul> <li>Session timer: Shows how long you've been canvassing</li> <li>Visit counter: Number of doors you've knocked</li> <li>Next door button: Navigate to nearest unvisited location</li> </ul> <p>Screenshot placeholder: Map controls showing timer, visit counter, and \"Next Door\" button</p>"},{"location":"v2/user-guides/volunteer-guide/#following-your-walking-route","title":"Following Your Walking Route","text":"<p>The purple line on the map is your suggested walking route.</p> <p>How the route works:</p> <ol> <li>Starts at your current location</li> <li>Connects to nearest unvisited location</li> <li>Then to next nearest unvisited location</li> <li>And so on, minimizing backtracking</li> </ol> <p>To follow the route:</p> <ol> <li>Look at the map</li> <li>Walk toward the first location on the purple line</li> <li>Your blue dot will move as you walk</li> <li>When you reach a location, tap the house icon</li> <li>Record your visit (see next section)</li> <li>The route automatically updates to skip that location</li> </ol> <p>Use Turn-by-Turn Navigation</p> <p>For long distances, tap a location and select \"Get Directions\" to open Google Maps for turn-by-turn navigation.</p> <p>Screenshot placeholder: Walking route showing path from current location through several unvisited houses</p>"},{"location":"v2/user-guides/volunteer-guide/#recording-visits","title":"Recording Visits","text":"<p>To record a visit:</p> <ol> <li>Knock on the door (or ring doorbell)</li> <li>Wait 20-30 seconds</li> <li>If someone answers, have your conversation</li> <li>After the interaction (or non-interaction), tap the house icon on the map</li> <li>A bottom sheet slides up with the visit recording form</li> </ol> <p>Screenshot placeholder: Bottom sheet showing visit recording form with outcome buttons</p>"},{"location":"v2/user-guides/volunteer-guide/#visit-outcomes","title":"Visit Outcomes","text":"<p>You must select one of seven outcomes:</p>"},{"location":"v2/user-guides/volunteer-guide/#1-not_home-nobody-answered","title":"1. NOT_HOME (Nobody Answered)","text":"<p>When to use:</p> <ul> <li>Nobody answered the door</li> <li>Waited 20-30 seconds</li> <li>No signs of activity</li> </ul> <p>What happens:</p> <ul> <li>Location marked as \"not home\"</li> <li>Could try again later</li> <li>No other details needed</li> </ul>"},{"location":"v2/user-guides/volunteer-guide/#2-refused-refused-to-talk","title":"2. REFUSED (Refused to Talk)","text":"<p>When to use:</p> <ul> <li>Someone answered but declined to talk</li> <li>\"Not interested\"</li> <li>Closed door immediately</li> </ul> <p>What happens:</p> <ul> <li>Location marked as \"refused\"</li> <li>Don't visit again (respect their wishes)</li> <li>Optional: Add notes about interaction</li> </ul>"},{"location":"v2/user-guides/volunteer-guide/#3-spoke_with-had-a-conversation","title":"3. SPOKE_WITH (Had a Conversation)","text":"<p>When to use:</p> <ul> <li>Had a conversation (any length)</li> <li>Discussed campaign issues</li> <li>May or may not be supportive</li> </ul> <p>What happens:</p> <ul> <li>Prompts for support level (see below)</li> <li>Can add notes about conversation</li> <li>Can request sign placement</li> </ul> <p>Most important outcome \u2014 this is your goal!</p>"},{"location":"v2/user-guides/volunteer-guide/#4-moved_away-resident-moved","title":"4. MOVED_AWAY (Resident Moved)","text":"<p>When to use:</p> <ul> <li>Current resident says previous resident moved</li> <li>For sale / for rent sign</li> <li>Mailbox indicates new occupant</li> </ul> <p>What happens:</p> <ul> <li>Location marked as outdated</li> <li>Helps organizer update database</li> </ul>"},{"location":"v2/user-guides/volunteer-guide/#5-wrong_address-location-doesnt-exist","title":"5. WRONG_ADDRESS (Location Doesn't Exist)","text":"<p>When to use:</p> <ul> <li>Address doesn't exist (vacant lot, wrong number)</li> <li>Building demolished</li> <li>Address is commercial, not residential</li> </ul> <p>What happens:</p> <ul> <li>Flags location for removal from database</li> </ul>"},{"location":"v2/user-guides/volunteer-guide/#6-do_not_contact-asked-not-to-be-contacted","title":"6. DO_NOT_CONTACT (Asked Not to Be Contacted)","text":"<p>When to use:</p> <ul> <li>Resident explicitly asks not to be contacted again</li> <li>\"Please remove me from your list\"</li> <li>Hostile response</li> </ul> <p>What happens:</p> <ul> <li>Location permanently marked \"do not contact\"</li> <li>Will never appear on future walk sheets</li> </ul> <p>Respect Privacy</p> <p>Always honor \"do not contact\" requests immediately. It's legally required in many jurisdictions.</p>"},{"location":"v2/user-guides/volunteer-guide/#7-other-something-else","title":"7. OTHER (Something Else)","text":"<p>When to use:</p> <ul> <li>Situation doesn't fit other categories</li> <li>Special circumstances</li> </ul> <p>What happens:</p> <ul> <li>Prompts you to add notes explaining situation</li> </ul> <p>Screenshot placeholder: Outcome buttons showing seven options with icons</p>"},{"location":"v2/user-guides/volunteer-guide/#support-levels","title":"Support Levels","text":"<p>When you select SPOKE_WITH, you'll be asked to rate the resident's support level.</p> <p>Support Level Guide:</p>"},{"location":"v2/user-guides/volunteer-guide/#level_1-strong-support","title":"LEVEL_1: Strong Support","text":"<ul> <li>Definition: Enthusiastically supports your cause</li> <li>Indicators:</li> <li>\"Absolutely, I'm with you 100%\"</li> <li>Asks how they can help</li> <li>Already familiar with the issue</li> <li>Wants to volunteer</li> <li>Action: Ask if they want a yard sign, ask for volunteer signup</li> </ul>"},{"location":"v2/user-guides/volunteer-guide/#level_2-leaning-support","title":"LEVEL_2: Leaning Support","text":"<ul> <li>Definition: Generally supportive but not highly engaged</li> <li>Indicators:</li> <li>\"Yeah, I agree with that\"</li> <li>Positive but brief response</li> <li>Willing to listen</li> <li>May have some questions</li> <li>Action: Provide information, ask if they want updates</li> </ul>"},{"location":"v2/user-guides/volunteer-guide/#level_3-undecided-neutral","title":"LEVEL_3: Undecided / Neutral","text":"<ul> <li>Definition: Hasn't made up their mind</li> <li>Indicators:</li> <li>\"I need to think about it\"</li> <li>Sees both sides of the issue</li> <li>Doesn't have strong opinion</li> <li>Wants more information</li> <li>Action: Provide balanced information, offer to follow up</li> </ul>"},{"location":"v2/user-guides/volunteer-guide/#level_4-opposition","title":"LEVEL_4: Opposition","text":"<ul> <li>Definition: Opposed to your cause</li> <li>Indicators:</li> <li>\"I disagree with that\"</li> <li>Supports opposing position</li> <li>Strong opinions against</li> <li>Action: Thank them for their time, respectfully end conversation</li> </ul> <p>Be Honest</p> <p>Record the support level as accurately as possible. This data helps your organizer understand the community and plan strategy.</p> <p>Screenshot placeholder: Support level selector showing LEVEL_1 through LEVEL_4 with descriptions</p>"},{"location":"v2/user-guides/volunteer-guide/#requesting-signs","title":"Requesting Signs","text":"<p>If the resident is supportive (LEVEL_1 or LEVEL_2), you can mark that they want a yard sign.</p> <p>To record a sign request:</p> <ol> <li>After selecting support level</li> <li>Toggle \"Wants Sign\" to ON</li> <li>Optionally add notes (e.g., \"Prefers small sign\", \"Needs post\")</li> </ol> <p>Your organizer will see this request and arrange sign delivery.</p> <p>Screenshot placeholder: Sign request toggle and notes field in visit form</p>"},{"location":"v2/user-guides/volunteer-guide/#taking-notes-and-photos","title":"Taking Notes and Photos","text":"<p>Notes field:</p> <p>Use the notes field to record:</p> <ul> <li>Key points from the conversation</li> <li>Specific concerns the resident mentioned</li> <li>Contact information (if they want follow-up)</li> <li>Delivery instructions for signs</li> <li>Any special circumstances</li> </ul> <p>Example notes:</p> <ul> <li>\"Very concerned about climate change. Has two kids. Wants to receive newsletter.\"</li> <li>\"Undecided on issue. Worried about cost. Wants more info on funding.\"</li> <li>\"Strong support. Already signed petition. Wants to volunteer. Email: john@example.com\"</li> </ul> <p>Photo upload (optional):</p> <p>Some organizations enable photo upload. You might take photos of:</p> <ul> <li>Yard sign placements</li> <li>Location identifiers (helps future canvassers)</li> <li>Special notes left by resident</li> </ul> <p>Privacy</p> <p>Never take photos of people without permission. Only photograph property/signs if allowed by your organizer.</p> <p>Screenshot placeholder: Notes textarea and photo upload button in visit form</p>"},{"location":"v2/user-guides/volunteer-guide/#saving-a-visit","title":"Saving a Visit","text":"<p>To save the visit:</p> <ol> <li>Select outcome</li> <li>Select support level (if spoke with resident)</li> <li>Add notes (optional)</li> <li>Toggle sign request (if applicable)</li> <li>Click \"Save Visit\"</li> </ol> <p>The bottom sheet closes, the location icon changes color, and your visit counter increments.</p> <p>Screenshot placeholder: Complete visit form with all fields filled and \"Save Visit\" button highlighted</p>"},{"location":"v2/user-guides/volunteer-guide/#skipping-a-location","title":"Skipping a Location","text":"<p>If you need to skip a location:</p> <ol> <li>Don't tap the house icon</li> <li>Walk to the next location on your route</li> </ol> <p>Reasons to skip:</p> <ul> <li>Dangerous dog</li> <li>Unsafe approach (icy steps, etc.)</li> <li>Location is inaccessible</li> </ul> <p>You can come back to skipped locations later in the session.</p>"},{"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":"<p>To allow location access:</p> <p>On iPhone:</p> <ol> <li>When app requests location, tap \"Allow While Using App\"</li> <li>Or go to Settings > Safari > Location > Allow</li> </ol> <p>On Android:</p> <ol> <li>When prompted, tap \"Allow\"</li> <li>Or go to Settings > Apps > Chrome > Permissions > Location > Allow</li> </ol> <p>Location Required</p> <p>The canvassing map requires location access to show your position and update the walking route.</p> <p>Screenshot placeholder: Location permission prompt on mobile browser</p>"},{"location":"v2/user-guides/volunteer-guide/#improving-gps-accuracy","title":"Improving GPS Accuracy","text":"<p>Tips for better GPS:</p> <ol> <li>Enable high accuracy mode</li> <li>iPhone: Settings > Privacy > Location Services > System Services > Improve Location</li> <li> <p>Android: Settings > Location > Google Location Accuracy > ON</p> </li> <li> <p>Ensure clear sky view</p> </li> <li>GPS works best outdoors</li> <li>Move away from tall buildings if possible</li> <li> <p>Trees and structures reduce accuracy</p> </li> <li> <p>Wait for signal</p> </li> <li>When you start session, GPS may take 30-60 seconds to lock</li> <li> <p>Blue circle will shrink as accuracy improves</p> </li> <li> <p>Keep phone unlocked</p> </li> <li>Some browsers pause location updates when screen is locked</li> <li> <p>Consider increasing screen timeout</p> </li> <li> <p>Use Wi-Fi</p> </li> <li>Even if not connected, enabling Wi-Fi improves location accuracy</li> <li>Wi-Fi scanning helps triangulate position</li> </ol> <p>Screenshot placeholder: Map showing blue location dot with large accuracy circle (poor) vs small circle (good)</p>"},{"location":"v2/user-guides/volunteer-guide/#next-door-button","title":"\"Next Door\" Button","text":"<p>The \"Next Door\" button at the bottom of the map automatically:</p> <ol> <li>Finds the nearest unvisited location</li> <li>Centers map on that location</li> <li>Highlights the location (pulses)</li> </ol> <p>When to use it:</p> <ul> <li>You've finished a visit and want to know where to go next</li> <li>You got turned around and need to reorient</li> <li>You want to skip the current location and find the next one</li> </ul> <p>Screenshot placeholder: \"Next Door\" button highlighted with arrow pointing to nearest unvisited location</p>"},{"location":"v2/user-guides/volunteer-guide/#gps-troubleshooting","title":"GPS Troubleshooting","text":"<p>If GPS isn't working:</p> <ol> <li>Refresh the page: Pull down to refresh</li> <li>Check permissions: Make sure location is allowed</li> <li>Toggle location off/on: In phone settings</li> <li>Restart browser: Close and reopen</li> <li>Try airplane mode toggle: Turn on/off to reset radios</li> <li>Check battery saver: Some battery saver modes disable GPS</li> <li>Contact your organizer: They can manually mark your visits</li> </ol>"},{"location":"v2/user-guides/volunteer-guide/#ending-your-session","title":"Ending Your Session","text":""},{"location":"v2/user-guides/volunteer-guide/#finishing-canvassing","title":"Finishing Canvassing","text":"<p>When you're done canvassing:</p> <ol> <li>Open the menu (hamburger icon, top-left)</li> <li>Tap \"End Session\"</li> <li>Review your session summary:</li> <li>Total visits</li> <li>Breakdown by outcome</li> <li>Session duration</li> <li>Support levels found</li> <li>Tap \"Confirm End Session\"</li> </ol> <p>Screenshot placeholder: End session confirmation showing session statistics</p>"},{"location":"v2/user-guides/volunteer-guide/#session-summary","title":"Session Summary","text":"<p>After ending, you'll see a summary screen with:</p> <p>Your results:</p> <ul> <li>Total visits: Doors you knocked</li> <li>Spoke with: Conversations had</li> <li>Support found: LEVEL_1 and LEVEL_2 residents</li> <li>Sign requests: Signs to deliver</li> <li>Session time: How long you canvassed</li> </ul> <p>What happens next:</p> <ul> <li>Your visits are saved to the database</li> <li>Your organizer can see your results</li> <li>You can view your activity history in My Activity</li> </ul> <p>Share Your Results</p> <p>Take a screenshot of your summary to share on social media and encourage other volunteers!</p> <p>Screenshot placeholder: Session summary screen showing statistics and \"Share Results\" button</p>"},{"location":"v2/user-guides/volunteer-guide/#abandoned-sessions","title":"Abandoned Sessions","text":"<p>If you forget to end your session, don't worry:</p> <ul> <li>Sessions older than 12 hours are automatically closed</li> <li>Your visit data is preserved</li> <li>Next time you log in, you can start a new session</li> </ul>"},{"location":"v2/user-guides/volunteer-guide/#viewing-your-activity","title":"Viewing Your Activity","text":""},{"location":"v2/user-guides/volunteer-guide/#my-activity-page","title":"My Activity Page","text":"<p>To view your canvassing history:</p> <ol> <li>Click \"My Activity\" in the top navigation</li> </ol> <p>The activity page shows:</p> <p>Statistics cards:</p> <ul> <li>Total visits: All-time visit count</li> <li>Doors knocked: Total locations visited</li> <li>Support found: LEVEL_1 and LEVEL_2 combined</li> <li>Signs requested: Total sign requests</li> </ul> <p>Outcome breakdown chart:</p> <ul> <li>Pie chart showing % of each outcome</li> <li>NOT_HOME, REFUSED, SPOKE_WITH, etc.</li> <li>Helps you see patterns</li> </ul> <p>Visit history table:</p> <ul> <li>Date and time</li> <li>Address visited</li> <li>Outcome</li> <li>Support level</li> <li>Notes</li> <li>Associated shift</li> </ul> <p>Screenshot placeholder: My Activity page showing statistics, pie chart, and visit history table</p>"},{"location":"v2/user-guides/volunteer-guide/#filtering-your-activity","title":"Filtering Your Activity","text":"<p>Available filters:</p> <ul> <li>Date range: Last 7 days, last 30 days, all time, custom</li> <li>Outcome: Show only specific outcomes</li> <li>Support level: Show only specific support levels</li> <li>Shift: Show only specific shifts</li> </ul> <p>Screenshot placeholder: Activity filters showing date range picker and outcome dropdown</p>"},{"location":"v2/user-guides/volunteer-guide/#exporting-your-data","title":"Exporting Your Data","text":"<p>To export your activity:</p> <ol> <li>Go to My Activity</li> <li>Apply filters (optional)</li> <li>Click \"Export CSV\"</li> <li>Open the file in Excel or Google Sheets</li> </ol> <p>The export includes all visible visits with full details.</p>"},{"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":"<p>To see where you've canvassed:</p> <ol> <li>Click \"My Routes\" in the top navigation</li> </ol> <p>Each past session shows:</p> <ul> <li>Map of the cut you canvassed</li> <li>Your path (GPS track, if available)</li> <li>Visited locations (colored by outcome)</li> <li>Session details: Date, duration, visit count</li> </ul> <p>Screenshot placeholder: My Routes showing map with GPS track and visited location markers</p>"},{"location":"v2/user-guides/volunteer-guide/#route-statistics","title":"Route Statistics","text":"<p>For each route, you can see:</p> <ul> <li>Distance traveled: Estimated walking distance</li> <li>Coverage: % of cut visited</li> <li>Average time per visit: How long each interaction took</li> <li>Efficiency: Visits per hour</li> </ul> <p>This helps you improve your canvassing technique over time.</p>"},{"location":"v2/user-guides/volunteer-guide/#mobile-tips","title":"Mobile Tips","text":""},{"location":"v2/user-guides/volunteer-guide/#battery-saving","title":"Battery Saving","text":"<p>Canvassing uses GPS continuously, which drains battery. To conserve:</p> <ol> <li>Lower screen brightness: Adjust in quick settings</li> <li>Enable battery saver (after GPS locks): Reduces background activity</li> <li>Close other apps: Free up resources</li> <li>Bring portable charger: Essential for long sessions</li> <li>Use low power mode (cautiously): May reduce GPS accuracy</li> </ol> <p>Expected battery life:</p> <ul> <li>2-3 hours of continuous canvassing</li> <li>Bring charger for sessions longer than 2 hours</li> </ul> <p>Screenshot placeholder: Phone battery settings showing low power mode and brightness slider</p>"},{"location":"v2/user-guides/volunteer-guide/#offline-considerations","title":"Offline Considerations","text":"<p>The canvassing app requires internet connection for:</p> <ul> <li>Loading the map</li> <li>Saving visits to the server</li> <li>Updating the walking route</li> </ul> <p>No Offline Mode</p> <p>Currently, there's no offline mode. Ensure you have cellular data or Wi-Fi before starting.</p> <p>If you lose connection:</p> <ul> <li>Your current location still updates (GPS works offline)</li> <li>You can still record visits (they're saved locally)</li> <li>Visits will sync when connection returns</li> <li>Map tiles may not load in new areas</li> </ul> <p>Tips:</p> <ul> <li>Check signal strength before starting session</li> <li>Start session while connected (loads map data)</li> <li>If rural area, load map of cut before leaving Wi-Fi</li> </ul>"},{"location":"v2/user-guides/volunteer-guide/#network-connectivity","title":"Network Connectivity","text":"<p>Minimum requirements:</p> <ul> <li>3G cellular data or better</li> <li>Low latency (< 500ms ping)</li> </ul> <p>Recommended:</p> <ul> <li>4G/LTE or better</li> <li>Wi-Fi for starting session (loads initial data faster)</li> </ul> <p>Data usage:</p> <ul> <li>~5-10 MB per hour of canvassing</li> <li>Map tiles are the largest data use</li> <li>Visit recording uses minimal data</li> </ul>"},{"location":"v2/user-guides/volunteer-guide/#safety-privacy","title":"Safety & Privacy","text":""},{"location":"v2/user-guides/volunteer-guide/#personal-safety-tips","title":"Personal Safety Tips","text":"<p>Before you go:</p> <ol> <li>Let someone know: Tell a friend/family where you'll be canvassing</li> <li>Bring a buddy: Canvass in pairs if possible</li> <li>Charge your phone: Essential for emergencies</li> <li>Wear comfortable shoes: You'll be walking a lot</li> <li>Check the weather: Dress appropriately</li> </ol> <p>While canvassing:</p> <ol> <li>Stay in public view: Don't enter homes or yards</li> <li>Trust your instincts: Skip locations that feel unsafe</li> <li>Avoid aggressive dogs: Use the \"skip\" function</li> <li>Stay hydrated: Bring water, especially in summer</li> <li>Take breaks: Rest every hour</li> <li>Be aware of traffic: Look both ways before crossing streets</li> </ol> <p>If you feel unsafe:</p> <ol> <li>Leave the area immediately</li> <li>Mark the location with outcome \"OTHER\" and note the safety concern</li> <li>Contact your organizer</li> <li>Call 911 if there's an emergency</li> </ol> <p>Safety First</p> <p>Never prioritize completing visits over your personal safety. It's always okay to skip a location or end your session early.</p> <p>Screenshot placeholder: Safety checklist infographic</p>"},{"location":"v2/user-guides/volunteer-guide/#privacy-of-resident-information","title":"Privacy of Resident Information","text":"<p>What you can do with resident data:</p> <ul> <li>Use it during your canvass session</li> <li>Record visit outcomes and notes</li> <li>Share relevant information with your organizer</li> </ul> <p>What you cannot do:</p> <ul> <li>Share resident information on social media</li> <li>Use contact info for personal purposes</li> <li>Sell or distribute the data</li> <li>Contact residents outside official campaign activities</li> </ul> <p>Legal obligations:</p> <ul> <li>Respect \"do not contact\" requests immediately</li> <li>Don't photograph residents without permission</li> <li>Don't share personal details residents tell you (unless they explicitly allow)</li> </ul> <p>Data you record is used for:</p> <ul> <li>Campaign strategy and planning</li> <li>Follow-up contact (official campaign only)</li> <li>Sign delivery coordination</li> <li>Voter outreach statistics</li> </ul> <p>Confidentiality</p> <p>Treat all resident information as confidential. Violating privacy can result in legal consequences and harm the campaign.</p>"},{"location":"v2/user-guides/volunteer-guide/#faqs","title":"FAQs","text":""},{"location":"v2/user-guides/volunteer-guide/#account-login","title":"Account & Login","text":"<p>Q: I forgot my password. How do I reset it?</p> <p>A: Click \"Forgot Password\" on the login page, enter your email, and check your email for reset instructions.</p> <p>Q: My email says I have a TEMP account. What does that mean?</p> <p>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.</p> <p>Q: Can I change my email address?</p> <p>A: Contact your organizer to change your email. You cannot change it yourself.</p>"},{"location":"v2/user-guides/volunteer-guide/#shifts","title":"Shifts","text":"<p>Q: I signed up for a shift but didn't receive a confirmation email.</p> <p>A: Check your spam folder. If still not there, contact your organizer to verify your signup.</p> <p>Q: Can I sign up a friend for a shift?</p> <p>A: Use the public signup form (one signup per person). Or ask your organizer to create accounts for multiple people.</p> <p>Q: What if I'm running late to a shift?</p> <p>A: Contact your organizer as soon as possible. You can still start canvassing when you arrive.</p> <p>Q: I don't see any shifts. When will more be added?</p> <p>A: Your organizer creates shifts as needed. Check back regularly or ask when the next shift will be scheduled.</p>"},{"location":"v2/user-guides/volunteer-guide/#canvassing_1","title":"Canvassing","text":"<p>Q: What should I say at the door?</p> <p>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)</p> <p>Q: What if someone gets angry?</p> <p>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.</p> <p>Q: Can I canvass outside my assigned cut?</p> <p>A: No, stick to your assigned territory. Other volunteers may be assigned to other cuts, and visiting outside your area creates duplication.</p> <p>Q: What if I make a mistake recording a visit?</p> <p>A: Contact your organizer. They can edit visit records in the admin panel.</p> <p>Q: The walking route seems inefficient. Can I change it?</p> <p>A: The route is generated automatically. You can visit locations in any order you prefer\u2014the route is just a suggestion.</p> <p>Q: What if it starts raining?</p> <p>A: Your safety comes first. End your session and seek shelter. You can resume canvassing later.</p>"},{"location":"v2/user-guides/volunteer-guide/#technical-issues","title":"Technical Issues","text":"<p>Q: The map won't load.</p> <p>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</p> <p>Q: My location is wrong on the map.</p> <p>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</p> <p>Q: I can't save a visit.</p> <p>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</p> <p>Q: The app is slow.</p> <p>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</p> <p>Q: I accidentally ended my session. Can I resume?</p> <p>A: No, sessions cannot be resumed. Start a new session to continue canvassing.</p>"},{"location":"v2/user-guides/volunteer-guide/#data-privacy","title":"Data & Privacy","text":"<p>Q: What data do you collect about me?</p> <p>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)</p> <p>Q: Is my location tracked when I'm not canvassing?</p> <p>A: No, location is only accessed when you have an active canvassing session. Close your browser when done to ensure no tracking.</p> <p>Q: Can other volunteers see my activity?</p> <p>A: Other volunteers cannot see your activity. Only administrators can view visit records and statistics.</p> <p>Q: Can I delete my account?</p> <p>A: Contact your organizer to request account deletion. This will remove your personal information but preserve anonymized visit records for campaign statistics.</p> <p>Q: What happens to the data I collect?</p> <p>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)</p> <p>Data is never sold or shared with third parties.</p>"},{"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":"<p>Error: \"No active shift found\"</p> <p>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.</p> <p>Error: \"Shift has no cut assigned\"</p> <p>Solution: The shift you signed up for doesn't have a territory assigned. Contact your organizer to assign a cut to the shift.</p> <p>Error: \"You already have an active session\"</p> <p>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.</p>"},{"location":"v2/user-guides/volunteer-guide/#gps-not-working","title":"GPS Not Working","text":"<p>Symptoms: Blue location dot doesn't appear or doesn't move</p> <p>Solutions:</p> <ol> <li>Enable location permissions:</li> <li>iPhone: Settings > Safari > Location Services > While Using</li> <li>Android: Settings > Apps > Chrome > Permissions > Location > Allow</li> <li>Refresh the page: Pull down to refresh</li> <li>Check GPS signal: Move to an area with clear sky view</li> <li>Restart location services: Toggle location off/on in phone settings</li> <li>Try a different browser: Some browsers have better GPS support</li> </ol>"},{"location":"v2/user-guides/volunteer-guide/#walking-route-not-updating","title":"Walking Route Not Updating","text":"<p>Symptoms: Purple line doesn't change after completing visits</p> <p>Solutions:</p> <ol> <li>Refresh the map: Pull down to refresh</li> <li>Check internet connection: Route updates require server communication</li> <li>Wait 30 seconds: Updates may be delayed</li> <li>Manually navigate: Use \"Next Door\" button instead of following line</li> </ol>"},{"location":"v2/user-guides/volunteer-guide/#visit-wont-save","title":"Visit Won't Save","text":"<p>Symptoms: \"Save Visit\" button doesn't work or shows error</p> <p>Solutions:</p> <ol> <li>Check required fields: Make sure you selected an outcome</li> <li>Check internet connection: Visits save to server (requires connection)</li> <li>Try again: Close bottom sheet and tap location again</li> <li>Refresh page: Pull down to refresh</li> <li>Record offline: If persistently failing, write down visit details and report to organizer later</li> </ol>"},{"location":"v2/user-guides/volunteer-guide/#bottom-sheet-wont-close","title":"Bottom Sheet Won't Close","text":"<p>Symptoms: Visit recording form stays open after saving</p> <p>Solutions:</p> <ol> <li>Swipe down: Swipe bottom sheet downward to close</li> <li>Tap outside: Tap on the map area</li> <li>Refresh page: Pull down to refresh</li> </ol>"},{"location":"v2/user-guides/volunteer-guide/#getting-help","title":"Getting Help","text":"<p>If you have technical issues during canvassing:</p> <ol> <li>Try basic troubleshooting: Refresh page, check connection</li> <li>Continue canvassing: Use \"Next Door\" button and visual map</li> <li>Take notes: Write down visit details if app fails</li> <li>Report to organizer: After session, explain what happened</li> </ol> <p>If you have questions about canvassing technique:</p> <ol> <li>Ask your organizer: Before the shift</li> <li>Consult the script: Your organizer should provide talking points</li> <li>Watch experienced volunteers: Learn by observing</li> </ol> <p>If you have account or scheduling issues:</p> <ol> <li>Contact your organizer: They have admin access to fix account problems</li> <li>Check your email: Look for notifications about shift changes</li> <li>Review this guide: Many common questions are answered here</li> </ol>"},{"location":"v2/user-guides/volunteer-guide/#related-documentation","title":"Related Documentation","text":"<ul> <li>Admin Guide: For organizers and administrators</li> <li>Campaign Manager Guide: Guide to running advocacy campaigns</li> <li>Map Organizer Guide: Guide to managing territories and canvassing operations</li> <li>Map Module Features: Technical documentation on canvassing system</li> <li>Canvassing System: Detailed technical documentation</li> </ul> <p>Last updated: February 2026 (V2 complete)</p> <p>Need help? Contact your organizer or visit the documentation at <code>/docs</code>.</p>"},{"location":"blog/archive/2025/","title":"2025","text":""}]} |